fix(ai): keep chat deploy value-only; handle draft-only rows and refresh draft counts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-10 01:33:02 +02:00
co-authored by Claude Opus 4.8
parent 069db3a2cf
commit 95942ca815
2 changed files with 90 additions and 6 deletions
@@ -166,6 +166,10 @@ vi.mock('./rawAppBundlerBridge', () => ({
}))
}))
vi.mock('$lib/workspaceDrafts.svelte', () => ({
invalidateWorkspaceDrafts: vi.fn()
}))
import {
globalTools,
globalToolsFor,
@@ -176,6 +180,7 @@ import {
setOpenPreviewHandler
} from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import { bundleRawAppDraft } from './rawAppBundlerBridge'
import {
@@ -929,6 +934,70 @@ describe('global AI tools', () => {
).rejects.toThrow('trigger_kind is required')
})
it('discards a draft-only item that has no separate draft row', async () => {
// An item that exists ONLY as a draft_only row, with no draft-table entry
// (e.g. created elsewhere) — the row itself is the draft content.
await ScriptService.createScript({
workspace: WORKSPACE,
requestBody: {
path: 'f/scripts/orphan',
summary: 'Orphan',
content: 'export async function main() { return 1 }',
language: 'bun',
draft_only: true
} as any
})
expect(dbScriptDraft('f/scripts/orphan')).toBeUndefined()
const raw = await callGlobalTool('discard_local_draft', {
type: 'script',
path: 'f/scripts/orphan'
})
expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'script', path: 'f/scripts/orphan' })
// draft_only with no deployed version -> the whole item is removed.
expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/orphan'
})
})
it('deploys a chat-created script draft and clears the draft', async () => {
const content = 'export async function main() { return 1 }'
await callGlobalTool('write_script', {
path: 'f/scripts/to-deploy',
summary: 'Deploy me',
language: 'bun',
content
})
expect(dbScriptDraft('f/scripts/to-deploy')).toBeDefined()
vi.mocked(invalidateWorkspaceDrafts).mockClear()
const raw = await callGlobalTool('deploy_workspace_item', {
type: 'script',
path: 'f/scripts/to-deploy'
})
expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'script', path: 'f/scripts/to-deploy' })
// Deploy writes a real (non-draft_only) version; the backend clears the draft.
const createCalls = vi.mocked(ScriptService.createScript).mock.calls
const lastCreate = createCalls[createCalls.length - 1]?.[0] as any
expect(lastCreate.requestBody.draft_only).toBeFalsy()
expect(lastCreate.requestBody.content).toBe(content)
expect(dbScriptDraft('f/scripts/to-deploy')).toBeUndefined()
expect(invalidateWorkspaceDrafts).toHaveBeenCalledWith(WORKSPACE)
})
it('invalidates workspace draft counts after writing a draft', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/counted',
summary: '',
language: 'bun',
content: 'export async function main() {}'
})
expect(invalidateWorkspaceDrafts).toHaveBeenCalledWith(WORKSPACE)
})
it('preserves existing script metadata when writing over an existing item', async () => {
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
path: 'f/scripts/existing',
@@ -76,6 +76,7 @@ import {
import type { ContextElement } from '../context'
import { getDatatableTools } from '../datatableTools'
import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { emptySchema } from '$lib/utils'
import { inferArgs } from '$lib/infer'
import {
@@ -2352,8 +2353,9 @@ function liveDraftItem(
}
// The current chat draft for a script/flow: the live editor buffer if one is
// open, else the DB draft (`.draft`). `undefined` when no draft exists (or the
// item doesn't exist at all).
// open, else the DB draft (`.draft`), else the `draft_only` row itself (a
// never-deployed item whose row IS the draft content, even when no separate
// draft row exists). `undefined` when no draft exists (or the item doesn't).
async function loadDbDraftItem(
workspace: string,
type: DbDraftKind,
@@ -2364,14 +2366,16 @@ async function loadDbDraftItem(
try {
if (type === 'script') {
const script = await ScriptService.getScriptByPathWithDraft({ workspace, path })
if (!script.draft) return undefined
const item = scriptToItem(script.draft as NewScript, true)
const src = script.draft ?? (script.draft_only ? script : undefined)
if (!src) return undefined
const item = scriptToItem(src as NewScript, true)
item.isDraft = true
return item
}
const flow = await FlowService.getFlowByPathWithDraft({ workspace, path })
if (!flow.draft) return undefined
const item = flowToItem(flow.draft as Flow, true)
const src = flow.draft ?? (flow.draft_only ? flow : undefined)
if (!src) return undefined
const item = flowToItem(src as Flow, true)
item.isDraft = true
return item
} catch {
@@ -2399,6 +2403,8 @@ async function saveScriptDbDraft(
})
}
await DraftService.createDraft({ workspace, requestBody: { path, typ: 'script', value: draft } })
// Refresh mounted draft-count consumers (Drafts banner, compare page).
invalidateWorkspaceDrafts(workspace)
}
// Persist a flow DB draft, creating the backing `draft_only` row first when the
@@ -2424,6 +2430,7 @@ async function saveFlowDbDraft(
})
}
await DraftService.createDraft({ workspace, requestBody: { path, typ: 'flow', value: draft } })
invalidateWorkspaceDrafts(workspace)
}
// Delete a script/flow DB draft. If the item exists ONLY as a draft
@@ -2459,6 +2466,7 @@ async function deleteScriptFlowDbDraft(
// Reset any open live editor (and clear a stale localStorage buffer).
deleteGlobalDraft(workspace, type, path)
invalidateWorkspaceDrafts(workspace)
}
async function writeScriptDraft(
@@ -3461,6 +3469,9 @@ async function deployDraft(
switch (type) {
case 'script': {
// Script deploy writes a complete new version (createScript) — omitted
// fields are reset, not inherited. The AI only changes value/summary, so
// carry ALL other metadata forward from the currently-deployed version.
const existing = (await ScriptService.existsScriptByPath({ workspace, path }))
? await ScriptService.getScriptByPath({ workspace, path })
: undefined
@@ -3477,6 +3488,7 @@ async function deployDraft(
break
}
case 'flow': {
// Flow update is a full replace; carry metadata forward from deployed.
const flowDraft = draft.value as FlowDraftValue
const existing = (await FlowService.existsFlowByPath({ workspace, path }))
? await FlowService.getFlowByPath({ workspace, path })
@@ -3626,6 +3638,9 @@ async function deployDraft(
}
deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true })
// Deploying create/update-deletes the DB draft server-side; refresh mounted
// draft-count consumers so the Drafts banner/count drops immediately.
invalidateWorkspaceDrafts(workspace)
// Reload the session preview if it's open on the deployed item. Map the
// deploy type to the preview kind — a raw app deploys under 'app' but the