diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 55cdf5e48a..a140d2c502 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -401,6 +401,7 @@ import { import { bundleRawAppDraft } from './rawAppBundlerBridge' import { AppService, + DraftService, EmailTriggerService, FlowService, FolderService, @@ -2493,6 +2494,30 @@ describe('global AI tools', () => { } }) + // Without names a chosen name reads as a path of its own, and a write under it would + // create a second draft beside the one it meant to edit. + it('refuses to write by a chosen name it could not load the drafts for', async () => { + const workspace = 'ws-no-draft-names' + seedBackendDraft( + 'raw_app', + 'u/admin/draft_unloadable', + { summary: 'Unloadable', draft_path: 'f/sales/unloadable', files: {}, runnables: {} }, + { workspace } + ) + vi.mocked(DraftService.listDrafts).mockRejectedValueOnce(new Error('server error')) + + await expect( + getGlobalTool('write_app_file').fn({ + args: { path: 'f/sales/unloadable', file_path: '/index.tsx', content: 'x' }, + workspace, + helpers: {}, + toolCallbacks, + toolId: 'no-names' + }) + ).rejects.toThrow(/Could not load this workspace's drafts/) + expect(getBackendDraft('raw_app', 'f/sales/unloadable', { workspace })).toBeUndefined() + }) + it('refuses a draft_path that two drafts are staged under', async () => { for (const storage of ['u/admin/draft_a', 'u/admin/draft_b']) { seedBackendDraft( diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 3c8bafc8cb..e480f73d13 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -8310,19 +8310,21 @@ async function deployDraft( deployedInSessionHandler?.({ sessionId, kind, path: draftStoragePath, deployedPath }) } + // Named by where the item landed, not by the path the call addressed it at: a draft is + // often addressed by its storage key, which nothing resolves to once the draft is gone. toolCallbacks.setToolStatus(toolId, { - content: `Deployed ${type} "${path}"`, + content: `Deployed ${type} "${deployedPath}"`, result: 'Deployed', actions }) return JSON.stringify( { success: true, - message: `Deployed draft ${type} "${path}" to the workspace. Draft removed.${ + message: `Deployed draft ${type} "${deployedPath}" to the workspace. Draft removed.${ deployNote ? ` ${deployNote}` : '' }`, type, - path, + path: deployedPath, triggerKind }, null, diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 3eb1cf3f92..b7faa59761 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -372,9 +372,16 @@ async function readDraftNames(workspace: string, path?: string): Promise { try { rows = await DraftService.listDrafts({ workspace }) } catch (e) { - // The names loaded earlier are kept: stale routing still sends a name to the draft - // it named a moment ago, while no table at all sends a write to the name itself, - // forking the draft in two. + // Without names, a chosen name reads as a path of its own: a write under it creates + // a second draft beside the one it meant to edit. Names loaded earlier are good + // enough — a draft does not leave the name it had a moment ago — but with none at + // all the caller is stopped instead. + if (!draftNamesByWorkspace.has(workspace)) { + throw new Error( + `Could not load this workspace's drafts, so "${path}" cannot be matched to the draft ` + + `it may name. Try again.` + ) + } console.warn('Could not refresh draft names', e) return }