diff --git a/CHANGELOG.md b/CHANGELOG.md index b69ab68..1c3af90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ contain breaking changes**; patch releases are fixes only. ## Unreleased +- **`@polymind-inc/agent-framework-anthropic`** — a session restored from persistence can replay a + turn that used the code-execution beta. The blocks describing a provider-executed call — code + execution, bash, the text editor — used to be replayed only from `rawRepresentation`, which + serialization deliberately strips, so a restored transcript dropped them and the turn's remaining + `tool_result` answered a call the request no longer contained, which the Messages API rejects. + The conversion now rebuilds `server_tool_use` and the code-execution result blocks from the typed + contents when the raw block is gone: calls stay paired with their results, stdout/stderr, files + and error codes survive, and for a turn whose typed form captures what the wire said the rebuilt + request is identical to the in-memory one. A transcript still holding `rawRepresentation` replays + the exact bytes, unchanged, and `rawRepresentation` is still never serialized. Error outputs + parsed from code-execution and text-editor failures now also carry the wire's code under + `ErrorContent.errorCode` (additive), which is what makes a restored error turn rebuild faithfully. + - **`@polymind-inc/agent-framework-foundry`** — `FoundryToolbox` now normalizes the tool declarations it exposes by the same shared rules the MCP client applies, instead of passing them through raw: a remote name containing characters providers refuse (anything outside diff --git a/packages/anthropic/src/code-execution-replay.test.ts b/packages/anthropic/src/code-execution-replay.test.ts new file mode 100644 index 0000000..ceb8596 --- /dev/null +++ b/packages/anthropic/src/code-execution-replay.test.ts @@ -0,0 +1,291 @@ +/** + * Replaying provider-executed turns after a session round-trips through persistence. + * + * `rawRepresentation` is not serialized, so a restored transcript arrives with only the typed + * contents. The conversion rebuilds the provider blocks from those — and for a turn whose typed + * form captures everything the wire said, the rebuilt request must be *identical* to the one the + * in-memory transcript produces from the raw blocks. These tests drive real wire fixtures through + * the receive-side parser, serialize and restore the message, and compare both paths. + */ +import type { Message } from '@polymind-inc/agent-framework-core'; +import { deserializeMessage, serializeMessage } from '@polymind-inc/agent-framework-core'; +import { describe, expect, it } from 'vitest'; +import { parseContentBlocks } from './from-anthropic.js'; +import { toAnthropicMessages } from './to-anthropic.js'; + +/** An assistant turn as the receive side reports it, straight from wire blocks. */ +function assistantTurn(blocks: readonly unknown[]): Message { + return { role: 'assistant', contents: parseContentBlocks(blocks) }; +} + +/** The message after a session save and restore: same typed fields, no raw representations. */ +function restored(msg: Message): Message { + return deserializeMessage(JSON.parse(JSON.stringify(serializeMessage(msg))) as never); +} + +const CODE_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'code_execution', input: { code: 'print(1)' } }, + { + type: 'code_execution_tool_result', + tool_use_id: 'srvtoolu_1', + content: { + type: 'code_execution_result', + stdout: 'ok\n', + stderr: '', + return_code: 0, + content: [{ type: 'code_execution_output', file_id: 'file_1' }], + }, + }, +]; + +const CODE_STRING_INPUT_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'code_execution', input: 'print(1)' }, + { + type: 'code_execution_tool_result', + tool_use_id: 'srvtoolu_1', + content: { type: 'code_execution_result', stdout: '1\n', stderr: '', return_code: 0, content: [] }, + }, +]; + +const CODE_MALFORMED_FILE_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'code_execution', input: { code: 'save()' } }, + { + type: 'code_execution_tool_result', + tool_use_id: 'srvtoolu_1', + content: { + type: 'code_execution_result', + stdout: '', + stderr: '', + return_code: 0, + // The first entry names no file; the receive side preserves it as unknown content, and the + // rebuilt payload must carry it back in place. + content: [{ type: 'code_execution_output' }, { type: 'code_execution_output', file_id: 'file_1' }], + }, + }, +]; + +const BASH_MALFORMED_FILE_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_2', name: 'bash_code_execution', input: { command: 'make' } }, + { + type: 'bash_code_execution_tool_result', + tool_use_id: 'srvtoolu_2', + content: { + type: 'bash_code_execution_result', + stdout: 'built\n', + stderr: '', + return_code: 0, + content: [ + { type: 'bash_code_execution_output', file_id: 7 }, + { type: 'bash_code_execution_output', file_id: 'file_2' }, + ], + }, + }, +]; + +const CODE_ERROR_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'code_execution', input: { code: 'boom()' } }, + { + type: 'code_execution_tool_result', + tool_use_id: 'srvtoolu_1', + content: { type: 'code_execution_tool_result_error', error_code: 'unavailable' }, + }, +]; + +const BASH_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_2', name: 'bash_code_execution', input: { command: 'ls' } }, + { + type: 'bash_code_execution_tool_result', + tool_use_id: 'srvtoolu_2', + content: { + type: 'bash_code_execution_result', + stdout: 'a\nb\n', + stderr: '', + return_code: 0, + content: [{ type: 'bash_code_execution_output', file_id: 'file_2' }], + }, + }, +]; + +const BASH_TIMEOUT_TURN = [ + { type: 'server_tool_use', id: 'srvtoolu_2', name: 'bash_code_execution', input: { command: 'sleep 999' } }, + { + type: 'bash_code_execution_tool_result', + tool_use_id: 'srvtoolu_2', + content: { type: 'bash_code_execution_tool_result_error', error_code: 'execution_time_exceeded' }, + }, +]; + +const EDITOR_VIEW_TURN = [ + { + type: 'server_tool_use', + id: 'srvtoolu_3', + name: 'text_editor_code_execution', + input: { command: 'view', path: '/tmp/a.txt' }, + }, + { + type: 'text_editor_code_execution_tool_result', + tool_use_id: 'srvtoolu_3', + content: { + type: 'text_editor_code_execution_view_result', + content: 'hello world', + file_type: 'text', + start_line: 1, + num_lines: 1, + }, + }, +]; + +const EDITOR_STR_REPLACE_TURN = [ + { + type: 'server_tool_use', + id: 'srvtoolu_3', + name: 'text_editor_code_execution', + input: { command: 'str_replace', path: '/tmp/a.txt', old_str: 'x', new_str: 'new line' }, + }, + { + type: 'text_editor_code_execution_tool_result', + tool_use_id: 'srvtoolu_3', + content: { + type: 'text_editor_code_execution_str_replace_result', + lines: ['new line'], + old_start: 5, + old_lines: 1, + new_start: 5, + new_lines: 1, + }, + }, +]; + +const EDITOR_CREATE_TURN = [ + { + type: 'server_tool_use', + id: 'srvtoolu_3', + name: 'text_editor_code_execution', + input: { command: 'create', path: '/tmp/b.txt', file_text: 'x' }, + }, + { + type: 'text_editor_code_execution_tool_result', + tool_use_id: 'srvtoolu_3', + content: { type: 'text_editor_code_execution_create_result', is_file_update: false }, + }, +]; + +const EDITOR_ERROR_TURN = [ + { + type: 'server_tool_use', + id: 'srvtoolu_3', + name: 'text_editor_code_execution', + input: { command: 'view', path: '/nope' }, + }, + { + type: 'text_editor_code_execution_tool_result', + tool_use_id: 'srvtoolu_3', + content: { + type: 'text_editor_code_execution_tool_result_error', + error_code: 'file_not_found', + error_message: 'File not found: /nope', + }, + }, +]; + +describe('restored provider-executed turns', () => { + it.each([ + ['code execution', CODE_TURN], + ['code execution with a string input', CODE_STRING_INPUT_TURN], + ['code execution with a malformed file entry', CODE_MALFORMED_FILE_TURN], + ['code execution error', CODE_ERROR_TURN], + ['bash with a malformed file entry', BASH_MALFORMED_FILE_TURN], + ['bash', BASH_TURN], + ['bash timeout', BASH_TIMEOUT_TURN], + ['text editor view', EDITOR_VIEW_TURN], + ['text editor str_replace', EDITOR_STR_REPLACE_TURN], + ['text editor create', EDITOR_CREATE_TURN], + ['text editor error', EDITOR_ERROR_TURN], + ])('replays a restored %s turn identically to the in-memory one', (_label, blocks) => { + const turn = assistantTurn(blocks); + + const inMemory = toAnthropicMessages([turn]); + const replayed = toAnthropicMessages([restored(turn)]); + + // The in-memory path replays the exact wire blocks; matching it means the rebuilt request is + // byte-equal to what the API sent, calls paired with their results included. + expect(replayed).toEqual(inMemory); + expect(inMemory).toEqual([{ role: 'assistant', content: blocks }]); + }); + + it('degrades an unrecognized stored error code to unavailable, never to a success payload', () => { + // A stored errorCode marks the error-payload variant unambiguously; a code this build's + // schema does not know must stay an error — flipping to a success-shaped result with + // return_code 0 would invert what the turn reported. + const turn = assistantTurn([ + { type: 'server_tool_use', id: 'srvtoolu_1', name: 'code_execution', input: { code: 'x' } }, + { + type: 'code_execution_tool_result', + tool_use_id: 'srvtoolu_1', + content: { type: 'code_execution_tool_result_error', error_code: 'brand_new_code' }, + }, + ]); + + const [message] = toAnthropicMessages([restored(turn)]); + const blocks = Array.isArray(message?.content) ? message.content : []; + + expect(blocks[1]).toEqual({ + type: 'code_execution_tool_result', + tool_use_id: 'srvtoolu_1', + content: { type: 'code_execution_tool_result_error', error_code: 'unavailable' }, + }); + }); + + it('does not let keyless contents correlate when inferring the call family', () => { + // An empty callId is not an identity: a keyless shell result must not flip a different + // keyless call's rebuilt family to bash. + const messages: Message[] = [ + { + role: 'assistant', + contents: [ + { type: 'code_interpreter_tool_call', inputs: [{ type: 'text', text: '{"code":"x"}' }] }, + { type: 'shell_tool_result', outputs: [] }, + ], + }, + ]; + + const [message] = toAnthropicMessages(messages); + const blocks = Array.isArray(message?.content) ? message.content : []; + + expect(blocks[0]).toMatchObject({ type: 'server_tool_use', name: 'code_execution' }); + }); + + it('keeps using the raw block when it is present', () => { + // The raw block is the exact bytes the provider sent; reconstruction is the fallback, not a + // replacement. An extra field this build does not model must survive an in-memory replay. + const marked = [{ ...CODE_TURN[0], extra_field: 'kept' }, CODE_TURN[1]]; + const [first] = toAnthropicMessages([assistantTurn(marked)]); + + expect(Array.isArray(first?.content) && first.content[0]).toMatchObject({ extra_field: 'kept' }); + }); + + it('does not serialize rawRepresentation', () => { + const serialized = JSON.stringify(serializeMessage(assistantTurn(CODE_TURN))); + expect(serialized).not.toContain('rawRepresentation'); + }); + + it('leaves an ordinary local tool exchange on the local mapping after a restore', () => { + // The text-editor detection keys on a `function_result` answering a provider-executed call; + // a result answering a local `function_call` must keep converting to a plain `tool_result`. + const local: Message = { + role: 'assistant', + contents: [ + { type: 'function_call', callId: 'c1', name: 'search', arguments: { q: 'x' } }, + { type: 'function_result', callId: 'c1', result: 'found' }, + ], + }; + + expect(toAnthropicMessages([restored(local)])).toEqual([ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c1', name: 'search', input: { q: 'x' } }] }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'c1', content: 'found', is_error: false }], + }, + ]); + }); +}); diff --git a/packages/anthropic/src/code-execution.test.ts b/packages/anthropic/src/code-execution.test.ts index 8db9832..57fdd1f 100644 --- a/packages/anthropic/src/code-execution.test.ts +++ b/packages/anthropic/src/code-execution.test.ts @@ -106,7 +106,16 @@ describe('code_execution_tool_result variants', () => { expect(content).toEqual({ type: 'code_interpreter_tool_result', callId: 'srvtoolu_1', - outputs: [{ type: 'error', message: 'execution_time_exceeded', rawRepresentation: inner }], + outputs: [ + { + type: 'error', + message: 'execution_time_exceeded', + // The code as itself, so a restored transcript can rebuild the error payload without + // guessing whether the message was a code or prose. + errorCode: 'execution_time_exceeded', + rawRepresentation: inner, + }, + ], rawRepresentation: block, }); }); @@ -320,7 +329,9 @@ describe('text_editor_code_execution_tool_result variants', () => { expect(content).toEqual({ type: 'function_result', callId: 'srvtoolu_1', - result: [{ type: 'error', message: 'File not found', rawRepresentation: inner }], + result: [ + { type: 'error', message: 'File not found', errorCode: 'file_not_found', rawRepresentation: inner }, + ], rawRepresentation: block, }); }); diff --git a/packages/anthropic/src/from-anthropic.ts b/packages/anthropic/src/from-anthropic.ts index add7d66..4a5d663 100644 --- a/packages/anthropic/src/from-anthropic.ts +++ b/packages/anthropic/src/from-anthropic.ts @@ -280,8 +280,19 @@ function hostedFiles(result: AnthropicBlock): Content[] { /** The outputs of a `code_execution_tool_result` payload. */ function codeExecutionOutputs(result: AnthropicBlock): Content[] { switch (result.type) { - case 'code_execution_tool_result_error': - return [{ type: 'error', message: String(result.error_code ?? ''), rawRepresentation: result }]; + case 'code_execution_tool_result_error': { + // `errorCode` carries the code as itself, so a restored transcript can rebuild the error + // payload without guessing whether the message was a code or prose. + const errorCode = String(result.error_code ?? ''); + return [ + { + type: 'error', + message: errorCode, + ...(errorCode === '' ? {} : { errorCode }), + rawRepresentation: result, + }, + ]; + } case 'code_execution_result': case 'encrypted_code_execution_result': { const outputs: Content[] = []; @@ -367,9 +378,18 @@ function textEditorOutputs(result: AnthropicBlock): Content[] { case 'text_editor_code_execution_tool_result_error': { // The error code gates the message, as in Python: a payload that reports a code but no // message describes itself through the raw representation rather than through prose. + // `errorCode` keeps the code itself, which a restored transcript needs to rebuild the + // error payload — the message alone cannot say which schema code produced it. const errorCode = String(result.error_code ?? ''); const errorMessage = errorCode === '' ? '' : (nonEmptyString(result.error_message) ?? ''); - return [{ type: 'error', message: errorMessage, rawRepresentation: result }]; + return [ + { + type: 'error', + message: errorMessage, + ...(errorCode === '' ? {} : { errorCode }), + rawRepresentation: result, + }, + ]; } case 'text_editor_code_execution_view_result': { const annotation = lineSpanAnnotation(result.start_line, result.num_lines, result); diff --git a/packages/anthropic/src/to-anthropic.ts b/packages/anthropic/src/to-anthropic.ts index fabc227..75efb81 100644 --- a/packages/anthropic/src/to-anthropic.ts +++ b/packages/anthropic/src/to-anthropic.ts @@ -150,11 +150,13 @@ const PROVIDER_EXECUTED_BLOCKS = new Set([ /** * The block to replay for content describing a call the provider ran itself. * - * These contents model an outcome, not a request the caller can make: there is no block to build - * from their fields, and the result blocks have to go back on the assistant turn that produced - * them rather than as a `tool_result` answering a call the transcript no longer contains. The - * block the API sent is replayed instead — the same forward-compatible rule {@link Content} of - * type `unknown` follows — so an exchange that used code execution survives the next request. + * These contents model an outcome, not a request the caller can make: the result blocks have to + * go back on the assistant turn that produced them rather than as a `tool_result` answering a + * call the transcript no longer contains. The block the API sent is replayed when it is still + * held — it is the exact bytes — and a restored transcript, whose raw representations were + * stripped by serialization, falls back to rebuilding the block from the typed contents (the + * `code_interpreter_tool_call` … cases below), so an exchange that used code execution survives + * the next request either way. */ function providerExecutedBlock(content: Content): AnthropicBlock | undefined { if ( @@ -172,6 +174,265 @@ function providerExecutedBlock(content: Content): AnthropicBlock | undefined { return raw; } +/** The wire names of the code-execution tool family, as `server_tool_use.name` carries them. */ +type CodeExecutionFamily = 'code_execution' | 'bash_code_execution' | 'text_editor_code_execution'; + +/** What the transcript-wide passes computed for the per-content conversion. */ +interface ConversionContext { + /** The `callId`s a `function_result` anywhere in the transcript answers. */ + answered: ReadonlySet; + /** Provider-executed call ids → the tool family inferred from the content answering them. */ + families: ReadonlyMap; +} + +/** + * Which code-execution family each provider-executed call belongs to. + * + * The typed `code_interpreter_tool_call` does not say which of the three code-execution tools + * ran — the receive side folds all of them into one content kind — but the kind of the content + * answering the call does: a `code_interpreter_tool_result` answers `code_execution`, a + * `shell_tool_result` answers `bash_code_execution`, and a `function_result` answers + * `text_editor_code_execution`. A call nothing answers reads as plain `code_execution`. + */ +function codeExecutionFamilies(messages: readonly Message[]): Map { + // Only a non-empty callId is an identity two contents can share — the same rule the unanswered- + // call filter applies. Keying `''` would let unrelated keyless contents correlate, flipping the + // rebuilt family of one degenerate call because a different keyless result happened to exist. + const families = new Map(); + for (const msg of messages) { + for (const content of msg.contents) { + if ( + content.type === 'code_interpreter_tool_call' && + content.callId !== undefined && + content.callId !== '' + ) { + families.set(content.callId, 'code_execution'); + } + } + } + for (const msg of messages) { + for (const content of msg.contents) { + const callId = 'callId' in content ? content.callId : undefined; + if (typeof callId !== 'string' || callId === '' || !families.has(callId)) { + continue; + } + if (content.type === 'shell_tool_result') { + families.set(callId, 'bash_code_execution'); + } else if (content.type === 'function_result') { + families.set(callId, 'text_editor_code_execution'); + } + } + } + return families; +} + +/** The `error_code` values each result schema accepts; anything else in that slot is prose. */ +const CODE_EXECUTION_ERROR_CODES = new Set([ + 'invalid_tool_input', + 'unavailable', + 'too_many_requests', + 'execution_time_exceeded', +]); +const BASH_ERROR_CODES = new Set([...CODE_EXECUTION_ERROR_CODES, 'output_file_too_large']); +const TEXT_EDITOR_ERROR_CODES = new Set([...CODE_EXECUTION_ERROR_CODES, 'file_not_found']); + +/** Rebuilds the `server_tool_use` block of a provider-executed call from its typed content. */ +function rebuiltServerToolUse(content: Content, family: CodeExecutionFamily): AnthropicBlock { + if (content.type !== 'code_interpreter_tool_call') { + return {}; + } + const text = content.inputs?.find((item) => item.type === 'text')?.text ?? ''; + // The receive side records an object input as its JSON and a string input as itself, so the + // faithful inverse parses when it can and otherwise sends the text back as the string it was. + // `server_tool_use.input` is `unknown` in the request schema, so a non-object round-trips as + // itself — unlike a local `tool_use.input`, whose object requirement is what the `raw` wrapping + // elsewhere serves. (A string input that happens to be valid JSON parses; the two spellings are + // indistinguishable once typed, and the parsed reading matches what an object input produced.) + let input: unknown; + try { + input = JSON.parse(text); + } catch { + input = text; + } + return { type: 'server_tool_use', id: content.callId ?? '', name: family, input }; +} + +/** The file entries a rebuilt result payload lists for its `hosted_file` and preserved contents. */ +function rebuiltFileEntries(items: readonly Content[], entryType: string): AnthropicBlock[] { + const entries: AnthropicBlock[] = []; + for (const item of items) { + if (item.type === 'hosted_file' && item.fileId !== undefined && item.fileId !== '') { + entries.push({ type: entryType, file_id: item.fileId }); + } else if (item.type === 'unknown' && item.unknownType === entryType) { + // A malformed entry — one naming no fetchable file — is preserved verbatim by the receive + // side; it goes back where it sat, so a restored replay carries the same list the wire did. + entries.push(serializeContent(item) as AnthropicBlock); + } + } + return entries; +} + +/** Whether a content item is a bash file entry the receive side placed before its shell result. */ +function isBashFileSibling(content: Content | undefined): boolean { + return ( + content?.type === 'hosted_file' || + (content?.type === 'unknown' && content.unknownType === 'bash_code_execution_output') + ); +} + +/** + * Whether this preserved entry is folded into the shell result that follows it. + * + * The receive side lists a bash run's file entries — malformed ones preserved as unknown content — + * as siblings *before* the `shell_tool_result`. Those are the shell payload's to carry: the + * rebuilt payload lists them again, and a raw replay already holds them inside the raw block, so + * emitting them standalone here would duplicate them at the top level. + */ +function foldsIntoFollowingShellResult(contents: readonly Content[], index: number): boolean { + const item = contents[index]; + if (item?.type !== 'unknown' || item.unknownType !== 'bash_code_execution_output') { + return false; + } + for (let cursor = index + 1; cursor < contents.length; cursor++) { + if (isBashFileSibling(contents[cursor])) { + continue; + } + return contents[cursor]?.type === 'shell_tool_result'; + } + return false; +} + +/** Rebuilds the payload of a `code_execution_tool_result` from the typed outputs. */ +function rebuiltCodeExecutionPayload(outputs: readonly Content[]): AnthropicBlock { + const [only] = outputs; + if (outputs.length === 1 && only?.type === 'error') { + // A stored `errorCode` marks the error-payload variant unambiguously, so it always rebuilds + // an error — a code the schema does not know degrades to `unavailable`, the same way the + // text-editor path degrades, rather than flipping the payload into a success-shaped result. + // A legacy session without the stored code is recognized by its message carrying a known + // code, since the receive side wrote the code as the message; a prose-only lone error stays + // a result payload, because that is how a stderr-only success run reads back. + const stored = only.errorCode ?? only.message ?? ''; + if (only.errorCode !== undefined || CODE_EXECUTION_ERROR_CODES.has(stored)) { + return { + type: 'code_execution_tool_result_error', + error_code: CODE_EXECUTION_ERROR_CODES.has(stored) ? stored : 'unavailable', + }; + } + } + // `return_code` is not part of the typed contents; a result that was worth reporting as outputs + // rather than an error reads as the shell convention for success. + return { + type: 'code_execution_result', + stdout: outputs + .filter((item): item is Content & { type: 'text' } => item.type === 'text') + .map((item) => item.text) + .join(''), + stderr: outputs + .filter((item): item is Content & { type: 'error' } => item.type === 'error') + .map((item) => item.message ?? '') + .join('\n'), + return_code: 0, + content: rebuiltFileEntries(outputs, 'code_execution_output'), + }; +} + +/** Rebuilds the payload of a `bash_code_execution_tool_result` from the shell output and files. */ +function rebuiltBashPayload(outputs: readonly Content[], files: readonly Content[]): AnthropicBlock { + const output = outputs.find( + (item): item is Content & { type: 'shell_command_output' } => item.type === 'shell_command_output', + ); + if (output?.timedOut === true) { + return { type: 'bash_code_execution_tool_result_error', error_code: 'execution_time_exceeded' }; + } + // The receive side reports an error payload as a shell output whose stderr carries the code and + // nothing else; a code in that position with no other observation rebuilds the error variant. + if ( + output !== undefined && + output.stdout === undefined && + output.exitCode === undefined && + BASH_ERROR_CODES.has(output.stderr ?? '') + ) { + return { type: 'bash_code_execution_tool_result_error', error_code: output.stderr ?? '' }; + } + return { + type: 'bash_code_execution_result', + stdout: output?.stdout ?? '', + stderr: output?.stderr ?? '', + return_code: output?.exitCode ?? 0, + content: rebuiltFileEntries(files, 'bash_code_execution_output'), + }; +} + +/** The `[start, lines]` span a line-range citation covers, or `undefined`. */ +function citedSpan(annotation: { + annotatedRegions?: readonly { type?: string; startIndex?: number; endIndex?: number }[]; +}): { start: number; lines: number } | undefined { + const region = annotation.annotatedRegions?.[0]; + if (region?.type !== 'text_span' || region.startIndex === undefined || region.endIndex === undefined) { + return undefined; + } + return { start: region.startIndex, lines: region.endIndex - region.startIndex }; +} + +/** + * Rebuilds a `text_editor_code_execution_tool_result` block from the `function_result` the text + * editor maps to. + * + * The typed form keeps the outcome but not the payload variant, so the variant is read back off + * what each one alone produces: the fixed `File update:` text of a create, an error content, a + * replaced/inserted line-span pair for a str_replace, and a single viewed span otherwise. The + * fields the receive side does not model default to what a text view reports (`file_type: 'text'`), + * and an error whose stored code is not one the schema accepts degrades to `unavailable` with the + * message preserved, rather than an invalid request. + */ +function rebuiltTextEditorResult(callId: string, result: unknown): AnthropicBlock { + const items = Array.isArray(result) ? (result as Content[]) : []; + const first = items[0]; + let payload: AnthropicBlock; + if (first?.type === 'error') { + const stored = first.errorCode ?? ''; + payload = { + type: 'text_editor_code_execution_tool_result_error', + error_code: TEXT_EDITOR_ERROR_CODES.has(stored) ? stored : 'unavailable', + ...(first.message === undefined || first.message === '' ? {} : { error_message: first.message }), + }; + } else if (first?.type === 'text' && /^File update: (true|false)$/.test(first.text)) { + payload = { + type: 'text_editor_code_execution_create_result', + is_file_update: first.text === 'File update: true', + }; + } else if (first?.type === 'text') { + const spans = (first.annotations ?? []).flatMap((annotation) => { + const span = citedSpan(annotation); + return span === undefined ? [] : [{ span, snippet: 'snippet' in annotation }]; + }); + const isStrReplace = spans.length >= 2 || spans.some((entry) => entry.snippet); + if (isStrReplace) { + const [oldSpan, newSpan] = spans.length >= 2 ? [spans[0], spans[1]] : [undefined, spans[0]]; + payload = { + type: 'text_editor_code_execution_str_replace_result', + ...(first.text === '' ? {} : { lines: first.text.split('\n') }), + ...(oldSpan === undefined ? {} : { old_start: oldSpan.span.start, old_lines: oldSpan.span.lines }), + ...(newSpan === undefined ? {} : { new_start: newSpan.span.start, new_lines: newSpan.span.lines }), + }; + } else { + const viewed = spans[0]?.span; + payload = { + type: 'text_editor_code_execution_view_result', + content: first.text, + file_type: 'text', + ...(viewed === undefined ? {} : { start_line: viewed.start, num_lines: viewed.lines }), + }; + } + } else { + // Nothing typed to rebuild from — an empty view says "the editor reported nothing" without + // inventing an outcome. + payload = { type: 'text_editor_code_execution_view_result', content: '', file_type: 'text' }; + } + return { type: 'text_editor_code_execution_tool_result', tool_use_id: callId, content: payload }; +} + /** * Converts one framework content item into Messages API blocks. * @@ -179,13 +440,52 @@ function providerExecutedBlock(content: Content): AnthropicBlock | undefined { * the `thinking` block before it instead of producing one of its own (Python * `_prepare_message_for_anthropic`). */ -function appendContent(blocks: AnthropicBlock[], content: Content, answered: ReadonlySet): void { +function appendContent( + blocks: AnthropicBlock[], + contents: readonly Content[], + index: number, + ctx: ConversionContext, +): void { + const content = contents[index] as Content; const providerExecuted = providerExecutedBlock(content); if (providerExecuted !== undefined) { blocks.push(providerExecuted); return; } switch (content.type) { + case 'code_interpreter_tool_call': { + // Reached only without a raw block — a restored transcript — since the raw path above + // replays the exact bytes otherwise. The same holds for the result cases below. + blocks.push(rebuiltServerToolUse(content, ctx.families.get(content.callId ?? '') ?? 'code_execution')); + return; + } + case 'code_interpreter_tool_result': { + blocks.push({ + type: 'code_execution_tool_result', + tool_use_id: content.callId ?? '', + content: rebuiltCodeExecutionPayload(content.outputs ?? []), + }); + return; + } + case 'shell_tool_result': { + // The files a bash run produced sit as siblings *before* the shell result — that is where + // the receive side put them, malformed entries preserved as unknown content included — so + // the rebuilt payload gathers the contiguous run immediately preceding this content. + const files: Content[] = []; + for (let cursor = index - 1; cursor >= 0; cursor--) { + const sibling = contents[cursor]; + if (!isBashFileSibling(sibling)) { + break; + } + files.unshift(sibling as Content); + } + blocks.push({ + type: 'bash_code_execution_tool_result', + tool_use_id: content.callId ?? '', + content: rebuiltBashPayload(content.outputs ?? [], files), + }); + return; + } case 'text': { // The API rejects empty text blocks. if (content.text !== '') { @@ -227,7 +527,7 @@ function appendContent(blocks: AnthropicBlock[], content: Content, answered: Rea // abandoned stream, a fatal middleware abort, a declaration-only tool), and the OpenAI // conversion already filters it the same way, so one rule governs every provider. An empty // callId is never a pairable identity and is dropped on the same grounds. - if (content.callId === '' || !answered.has(content.callId)) { + if (content.callId === '' || !ctx.answered.has(content.callId)) { return; } blocks.push({ @@ -245,6 +545,13 @@ function appendContent(blocks: AnthropicBlock[], content: Content, answered: Rea if (content.callId === '') { return; } + if (ctx.families.get(content.callId) === 'text_editor_code_execution') { + // The text editor's outcome is typed as an ordinary function result, but it answers a + // provider-executed call: a restored transcript rebuilds the provider's own result block, + // since a `tool_result` would answer a `tool_use` the request no longer contains. + blocks.push(rebuiltTextEditorResult(content.callId, content.result)); + return; + } blocks.push({ type: 'tool_result', tool_use_id: content.callId, @@ -280,6 +587,9 @@ function appendContent(blocks: AnthropicBlock[], content: Content, answered: Rea return; } case 'unknown': { + if (foldsIntoFollowingShellResult(contents, index)) { + return; + } // Forward compatibility: a block this build does not model is replayed // exactly as it arrived, so a transcript survives a round trip through the framework. const raw = content.rawRepresentation; @@ -323,11 +633,11 @@ function roleForBlock(block: AnthropicBlock, fallback: 'user' | 'assistant'): 'u * framework models a whole exchange as content — but Messages API insists that tool calls are * assistant turns and results are user turns (Python `_prepare_message_groups_for_anthropic`). */ -function messageGroups(msg: Message, answered: ReadonlySet): AnthropicMessage[] { +function messageGroups(msg: Message, ctx: ConversionContext): AnthropicMessage[] { const fallback = ROLE_MAP[msg.role] ?? 'user'; const blocks: AnthropicBlock[] = []; - for (const content of msg.contents) { - appendContent(blocks, content, answered); + for (let index = 0; index < msg.contents.length; index++) { + appendContent(blocks, msg.contents, index, ctx); } if (blocks.length === 0) { return []; @@ -374,11 +684,12 @@ function hasToolUse(msg: AnthropicMessage): boolean { */ export function toAnthropicMessages(messages: readonly Message[]): AnthropicMessage[] { const rest = messages[0]?.role === 'system' ? messages.slice(1) : messages; - // Collected across the whole transcript, so a call answered in a later message stays a call. - const answered = answeredCallIds(rest); + // Both collected across the whole transcript, so a call answered in a later message stays a + // call and a provider-executed exchange is recognized wherever its halves sit. + const ctx: ConversionContext = { answered: answeredCallIds(rest), families: codeExecutionFamilies(rest) }; const out: AnthropicMessage[] = []; for (const msg of rest) { - out.push(...messageGroups(msg, answered)); + out.push(...messageGroups(msg, ctx)); } const last = out[out.length - 1]; if (last !== undefined && last.role === 'assistant' && !hasToolUse(last)) {