Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/renderer/src/ai/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,18 @@ export interface RunAIOptions {
relevantTypeIds?: Set<string>
}

/** 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
Expand Down Expand Up @@ -153,7 +157,7 @@ export async function runAIPrompt(opts: RunAIOptions): Promise<RunAIResult> {
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]`,
Expand All @@ -167,7 +171,7 @@ export async function runAIPrompt(opts: RunAIOptions): Promise<RunAIResult> {
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
Expand Down
24 changes: 15 additions & 9 deletions tests/aiRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' }] })
Expand Down Expand Up @@ -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/)
})
})

Expand Down
Loading