diff --git a/src/renderer/src/ai/runner.ts b/src/renderer/src/ai/runner.ts index b63c191..defbd62 100644 --- a/src/renderer/src/ai/runner.ts +++ b/src/renderer/src/ai/runner.ts @@ -52,14 +52,18 @@ export interface RunAIOptions { relevantTypeIds?: Set } -/** Prepends a fresh text block to a turn's content ahead of sending it — - * used to carry the live diagram-state snapshot on the current round's new +/** Appends a fresh text block to a turn's content ahead of sending it — used + * to carry the live diagram-state snapshot on the current round's new * content (the initial prompt on round 1, the tool-results message on * round 2+) instead of a separate system message that would change every - * round and break the cacheable prefix in front of it (see below). */ -function withLeadingText(content: string | ChatContentBlock[], text: string): ChatContentBlock[] { + * round and break the cacheable prefix in front of it (see below). Must be + * APPENDED, not prepended: Anthropic requires `tool_result` blocks to be + * the first content in the user turn immediately following the `tool_use` + * they answer — a leading block ahead of them gets rejected with "tool_use + * ids were found without tool_result blocks immediately after". */ +function withTrailingText(content: string | ChatContentBlock[], text: string): ChatContentBlock[] { const blocks: ChatContentBlock[] = typeof content === 'string' ? [{ type: 'text', text: content }] : content - return [{ type: 'text', text }, ...blocks] + return [...blocks, { type: 'text', text }] } /** One human-readable line for a tool call — resolves tempIds/labels through @@ -153,7 +157,7 @@ export async function runAIPrompt(opts: RunAIOptions): Promise { const systemMessages = buildSystemMessages(metamodel, diagram.getNodes(), relevantTypeIds) // Re-read live state every round so the model sees whatever the previous - // round's tool calls just changed — attached as a leading text block on + // round's tool calls just changed — attached as a trailing text block on // THIS round's new turn (rather than a separate, ever-changing system // message) so everything before it stays a stable, appendable prefix. // This bakes a (soon stale) snapshot permanently into `turns[last]`, @@ -167,7 +171,7 @@ export async function runAIPrompt(opts: RunAIOptions): Promise { diagram.getViews?.(), ) const current = turns[turns.length - 1] - current.content = withLeadingText(current.content, contextText) + current.content = withTrailingText(current.content, contextText) if (round > 1) { // Clear the previous marker (else it'd accumulate past the 4-block diff --git a/tests/aiRunner.test.ts b/tests/aiRunner.test.ts index 09378e9..abb7f8a 100644 --- a/tests/aiRunner.test.ts +++ b/tests/aiRunner.test.ts @@ -202,17 +202,20 @@ describe('runAIPrompt — end-to-end with mocked Anthropic tool-calling', () => settings: anthropicSettings(), diagram: makeFacade(), }) - // The initial user turn now carries a leading diagram-state snapshot - // block ahead of the original prompt text (see ai/runner.ts's - // `withLeadingText`) instead of the live state going out as a separate, - // ever-changing system message — that's what makes the system prefix in - // front of it byte-stable and cacheable across rounds/stages. + // The initial user turn now carries a trailing diagram-state snapshot + // block after the original prompt text (see ai/runner.ts's + // `withTrailingText`) instead of the live state going out as a + // separate, ever-changing system message — that's what makes the + // system prefix in front of it byte-stable and cacheable across + // rounds/stages. It must be TRAILING, not leading: Anthropic requires + // `tool_result` blocks to be the first content in a user turn that + // follows a `tool_use` — a block ahead of them is rejected. expect(result.history[0].role).toBe('user') const firstContent = result.history[0].content expect(Array.isArray(firstContent)).toBe(true) expect(firstContent).toEqual([ - { type: 'text', text: expect.stringMatching(/Current diagram state/) }, { type: 'text', text: 'add an API system' }, + { type: 'text', text: expect.stringMatching(/Current diagram state/) }, ]) expect(result.history.some((m) => m.role === 'assistant' && Array.isArray(m.content))).toBe(true) expect(result.history.at(-1)).toEqual({ role: 'assistant', content: [{ type: 'text', text: 'Done.' }] }) @@ -240,10 +243,13 @@ describe('runAIPrompt — end-to-end with mocked Anthropic tool-calling', () => expect(cacheControlBlocks(reqs[1])).toBe(2) expect(cacheControlBlocks(reqs[2])).toBe(2) - // Every round's outgoing final message carries a fresh leading - // diagram-state snapshot ahead of the tool_result blocks. + // Every round's outgoing final message carries a fresh diagram-state + // snapshot APPENDED after its tool_result blocks — never before them, + // since Anthropic requires tool_result to be the first content + // immediately following the tool_use it answers. const lastMsg2 = reqs[1].messages.at(-1) - expect(lastMsg2.content[0].text).toMatch(/Current diagram state/) + expect(lastMsg2.content[0].type).toBe('tool_result') + expect(lastMsg2.content.at(-1).text).toMatch(/Current diagram state/) }) })