diff --git a/apps/x/.gitignore b/apps/x/.gitignore index db195fb4..2c5f63fd 100644 --- a/apps/x/.gitignore +++ b/apps/x/.gitignore @@ -1,2 +1,3 @@ node_modules/ test-fixtures/ +*.tsbuildinfo diff --git a/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx b/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx index 3ec452f2..b1b57124 100644 --- a/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx +++ b/apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx @@ -82,7 +82,7 @@ describe('useSessionChat', () => { // A new turn streams in over the feed. act(() => { emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) }) - emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0, [user('q2')]) }) + emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) }) emit({ kind: 'turn-event', sessionId: S1, diff --git a/apps/x/apps/renderer/src/lib/session-chat/store.test.ts b/apps/x/apps/renderer/src/lib/session-chat/store.test.ts index 96a23abb..9c5e0e9c 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/store.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/store.test.ts @@ -143,7 +143,7 @@ describe('SessionChatStore', () => { await store.setSession(S1) emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go')))) - emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')]))) + emit(turnEvent(S1, 'turn-1', requested('turn-1', 0))) expect(store.getSnapshot().chatState?.isThinking).toBe(true) emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done')))) @@ -161,7 +161,7 @@ describe('SessionChatStore', () => { client.sessions.set(S1, sessionState(S1, [])) await store.setSession(S1) emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go')))) - emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')]))) + emit(turnEvent(S1, 'turn-1', requested('turn-1', 0))) emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'he' })) emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'y' })) expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('hey') @@ -199,7 +199,7 @@ describe('SessionChatStore', () => { // The feed attached mid-turn: we never saw turn_created for turn-9. client.turns.set('turn-9', [ created('turn-9', S1, user('missed')), - requested('turn-9', 0, [user('missed')]), + requested('turn-9', 0), ]) emit(turnEvent(S1, 'turn-9', completed('turn-9', 0, assistantText('caught up')))) await Promise.resolve() @@ -273,7 +273,7 @@ describe('SessionChatStore', () => { await store.setSession(S1) emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go')))) - emit(turnEvent(S1, 'turn-1', requested('turn-1', 0, [user('go')]))) + emit(turnEvent(S1, 'turn-1', requested('turn-1', 0))) emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done')))) emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1'))) expect(store.getSnapshot().chatState?.isProcessing).toBe(false) diff --git a/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts b/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts index 381f8cb9..d20c4bd0 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts @@ -46,16 +46,24 @@ export function created( } } -export function requested(turnId: string, index: number, messages: unknown[]): TEvent { +type RequestRef = + | { kind: 'context' } + | { kind: 'input' } + | { kind: 'assistant'; modelCallIndex: number } + | { kind: 'toolResult'; toolCallId: string } + +export function requested( + turnId: string, + index: number, + refs: RequestRef[] = [{ kind: 'input' }], +): TEvent { return { type: 'model_call_requested', turnId, ts: TS, modelCallIndex: index, request: { - systemPrompt: 'SYS', - messages: messages as never, - tools: [], + messages: refs, parameters: {}, }, } @@ -127,7 +135,7 @@ export function completedTurnLog( ): TEvent[] { return [ created(turnId, sessionId, user(question)), - requested(turnId, 0, [user(question)]), + requested(turnId, 0), completed(turnId, 0, assistantText(answer)), turnCompleted(turnId, answer), ] diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts index 4acbd6e9..4c43c7c1 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts @@ -87,14 +87,13 @@ describe('buildTurnConversation', () => { const call = assistantCalls(toolCallPart('tc1', 'echo', { x: 1 })) const state = reduceTurn([ created(T1, S1, user('run it')), - requested(T1, 0, [user('run it')]), + requested(T1, 0), completed(T1, 0, call), invocation(T1, 'tc1', 'echo'), toolResult(T1, 'tc1', 'echo', { echoed: true }), requested(T1, 1, [ - user('run it'), - call, - { role: 'tool', content: '{"echoed":true}', toolCallId: 'tc1', toolName: 'echo' }, + { kind: 'assistant', modelCallIndex: 0 }, + { kind: 'toolResult', toolCallId: 'tc1' }, ]), completed(T1, 1, assistantText('all done')), turnCompleted(T1, 'all done'), @@ -113,7 +112,7 @@ describe('buildTurnConversation', () => { it('marks permission-pending tools pending and running tools running', () => { const state = reduceTurn([ created(T1, S1), - requested(T1, 0, [user('hello')]), + requested(T1, 0), completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'), toolCallPart('r1', 'echo'))), { type: 'tool_permission_required', @@ -139,7 +138,7 @@ describe('buildTurnConversation', () => { } const state = reduceTurn([ created(T1, S1, input as never), - requested(T1, 0, [input]), + requested(T1, 0), { type: 'model_call_failed', turnId: T1, ts: TS, modelCallIndex: 0, error: 'boom' }, { type: 'turn_failed', turnId: T1, ts: TS, error: 'boom', usage: {} }, ]) @@ -154,7 +153,7 @@ describe('buildSessionChatState', () => { const prior = reduceTurn(completedTurnLog('turn-1', S1, 'first?', 'first answer')) const latest = reduceTurn([ created('turn-2', S1, user('second?')), - requested('turn-2', 0, [user('second?')]), + requested('turn-2', 0), ]) const state = buildSessionChatState([prior, latest], emptyOverlay()) expect( @@ -174,7 +173,7 @@ describe('buildSessionChatState', () => { it('exposes pending permissions as request events; waiting is not thinking', () => { const turn = reduceTurn([ created(T1, S1), - requested(T1, 0, [user('hello')]), + requested(T1, 0), completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand', { command: 'rm -rf /' }))), { type: 'tool_permission_required', @@ -209,7 +208,7 @@ describe('buildSessionChatState', () => { it('maps human decisions to responses and classifier decisions to auto-decisions', () => { const turn = reduceTurn([ created(T1, S1), - requested(T1, 0, [user('hello')]), + requested(T1, 0), completed(T1, 0, assistantCalls(toolCallPart('h1', 'echo'), toolCallPart('c1', 'echo'))), { type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'h1', toolName: 'echo', request: { kind: 'command', commandNames: ['x'] } }, { type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', request: { kind: 'command', commandNames: ['y'] } }, @@ -230,7 +229,7 @@ describe('buildSessionChatState', () => { it('exposes pending ask-human calls with question and options', () => { const turn = reduceTurn([ created(T1, S1), - requested(T1, 0, [user('hello')]), + requested(T1, 0), completed(T1, 0, assistantCalls(toolCallPart('ah1', 'ask-human', { question: 'Deploy?', options: ['Yes', 'No'] }))), { type: 'tool_invocation_requested', @@ -254,7 +253,7 @@ describe('buildSessionChatState', () => { it('stitches live tool output onto the matching tool item', () => { const turn = reduceTurn([ created(T1, S1), - requested(T1, 0, [user('hello')]), + requested(T1, 0), completed(T1, 0, assistantCalls(toolCallPart('tc1', 'executeCommand'))), invocation(T1, 'tc1', 'executeCommand'), ]) @@ -265,7 +264,7 @@ describe('buildSessionChatState', () => { }) it('surfaces streaming text as currentAssistantMessage', () => { - const turn = reduceTurn([created(T1, S1), requested(T1, 0, [user('hello')])]) + const turn = reduceTurn([created(T1, S1), requested(T1, 0)]) const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' }) expect(state.currentAssistantMessage).toBe('typing…') }) diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 904fcb02..9a0ca542 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -479,11 +479,15 @@ Every AI SDK `streamText` invocation performs exactly one model step. Tool execution is controlled manually by the turn loop. ```ts +type ModelRequestMessageRef = + | { kind: "context" } // the inline context block + | { kind: "input" } // the turn's user message + | { kind: "assistant"; modelCallIndex: number } // → model_call_completed + | { kind: "toolResult"; toolCallId: string }; // → tool_result + interface ModelRequest { - systemPrompt: string; - contextRef?: { previousTurnId: string }; - messages: ConversationMessage[]; - tools: ToolDescriptor[]; + contextRef?: { previousTurnId: string }; // call 0 only (cross-turn prefix) + messages: ModelRequestMessageRef[]; // what is NEW since the previous call parameters: Record; } @@ -497,24 +501,26 @@ interface ModelCallRequested extends BaseTurnEvent { `modelCallIndex` starts at `0` and increments monotonically for each primary agent model call in the turn. -`request` records the exact model input with one substitution: when the -turn's context is a reference, the resolved prefix is not re-inlined. -`contextRef` repeats the reference and `messages` contains only current-turn -messages — the turn input, this turn's prior assistant responses, and ordered -tool results — captured byte-for-byte as assembled immediately before -provider invocation. When the turn's context is inline, `contextRef` is -absent and `messages` begins with that inline context. The full request is -deterministically materializable because referenced turns are immutable; a -`materializeRequest(turnId, modelCallIndex)` debug utility composes resolver -output with the persisted messages. +`request` is a list of references into the turn's own events: every +referenced byte exists exactly once in the file. A request records only what +is new since the previous model call — call 0 is `[{context}?, {input}]` +(context only when inline and nonempty; cross-turn prefixes ride +`contextRef`); call N is `[{assistant: N-1}, …that batch's toolResults in +source order]`; a re-issued call after an interruption is `[]`. The system +prompt and tool set are not repeated: they are byte-identical to +`turn_created.agent.resolved` by construction. -Within-turn duplication is deliberate: each request restates the current -turn's accumulated messages even though they also exist as earlier events in -the same file. The persisted `messages` are the ground truth of what the loop -actually assembled and sent — including any assembly bugs — which a derived -reconstruction would mask. +The exact provider payload is rebuilt by the request composer +(`composeModelRequest` in core): resolved system prompt, wrapped tool +definitions, the materialized cross-turn prefix, and every request's +references resolved against the turn's events, all passed through the model +bridge's `encodeMessages` (the deterministic structural→wire conversion — +user-message context weaving, attachment rendering, tool-result enveloping). +The loop transmits exactly the composer's output, and debugging/audit calls +the same function, so the durable file plus the composer reproduce the wire +bytes and the two views cannot diverge. -`request` excludes credentials, auth headers, functions, model objects, and +Requests exclude credentials, auth headers, functions, model objects, and transport objects. The name `requested` is intentional. The event proves durable intent, not that @@ -1526,7 +1532,11 @@ but the first turn implementation does not enforce it. - Any event after a terminal turn event. - Mutation of immutable turn definition data. - `model_call_requested.contextRef` values inconsistent with the turn - definition's context. + definition's context, or present on a non-initial model call. +- Request references that do not match the transcript: call 0 must be + `[{context}?, {input}]`; call N must reference the previous completed + call's response and its batch's tool results in source order (or nothing, + after an interrupted call). The reducer validates `context` structurally but treats it as opaque; it never resolves references (section 6.6). @@ -1755,8 +1765,8 @@ tests for: closure results. - A reference to a missing or corrupt turn file is an infrastructure error and appends nothing. -- `materializeRequest` output equals resolver output plus persisted - current-turn request messages. +- The composed request equals what the loop sent (byte-for-byte property: + durable file + composer reproduce the provider payload). - The reducer never resolves references. ## 28. Suggested module layout diff --git a/apps/x/packages/core/src/sessions/sessions.test.ts b/apps/x/packages/core/src/sessions/sessions.test.ts index ad9809cd..fccdb948 100644 --- a/apps/x/packages/core/src/sessions/sessions.test.ts +++ b/apps/x/packages/core/src/sessions/sessions.test.ts @@ -86,7 +86,7 @@ function turnLog( turnId, ts: TS, modelCallIndex: 0, - request: { systemPrompt: "SYS", messages: [user("hi")], tools: [], parameters: {} }, + request: { messages: [{ kind: 'input' as const }], parameters: {} }, }; switch (status) { case "idle": diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts index a8eb7a2c..ea0fe990 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts @@ -26,7 +26,7 @@ function makeRegistry(parts: Array>, capture: InvokerOpt function request(overrides: Partial = {}): ModelStreamRequest { return { systemPrompt: "SYS", - messages: [{ role: "user", content: "hello" }], + messages: [{ role: "user", content: "hello" }] as ModelStreamRequest["messages"], tools: [ { toolId: "builtin:echo", @@ -107,9 +107,10 @@ describe("RealModelRegistry", () => { }, }); - // The invoker received the system prompt, converted messages, and tools. + // The invoker received the system prompt, the pre-encoded messages + // verbatim (encoding is the composer's job), and the wrapped tools. expect(capture[0].system).toBe("SYS"); - expect(capture[0].messages[0]).toMatchObject({ role: "user" }); + expect(capture[0].messages).toEqual([{ role: "user", content: "hello" }]); expect(Object.keys(capture[0].tools)).toEqual(["echo"]); }); @@ -152,6 +153,41 @@ describe("RealModelRegistry", () => { ).rejects.toThrowError("rate limited"); }); + it("encodeMessages produces the LLM-facing wire form (context woven, tool results enveloped)", async () => { + const registry = makeRegistry([], []); + const model = await registry.resolve({ provider: "openai", model: "gpt-test" }); + const encoded = model.encodeMessages([ + { + role: "user", + content: "list my downloads", + userMessageContext: { + currentDateTime: "2026-07-02T10:30:00Z", + middlePane: { kind: "empty" }, + }, + }, + { role: "tool", content: "[…files…]", toolCallId: "tc1", toolName: "file-list" }, + ]) as Array<{ role: string; content: unknown }>; + + // The user message is the woven wire text, not the internal structure. + expect(encoded[0].role).toBe("user"); + const userText = String(encoded[0].content); + expect(userText).toContain("2026-07-02T10:30:00Z"); + expect(userText).toContain("list my downloads"); + expect(userText).not.toContain("userMessageContext"); + + // Tool output rides the AI SDK tool-result envelope. + expect(encoded[1]).toMatchObject({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + output: { type: "text", value: "[…files…]" }, + }, + ], + }); + }); + it("stops promptly when the signal aborts mid-stream", async () => { const controller = new AbortController(); const registry = makeRegistry( diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts index 3ad9abf4..f79ef32d 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts @@ -64,6 +64,12 @@ export class RealModelRegistry implements IModelRegistry { const model = provider.languageModel(descriptor.model); return { descriptor, + // The structural -> wire conversion the app uses today: weaves + // userMessageContext into the user text, renders attachments, + // wraps tool output as tool-result parts. Deterministic and + // per-message, so composed requests are byte-stable. + encodeMessages: (messages) => + convertFromMessages(messages) as unknown as JsonValue[], stream: (request) => this.run(model, request), }; } @@ -90,7 +96,7 @@ export class RealModelRegistry implements IModelRegistry { const result = this.invoke({ model, system: request.systemPrompt, - messages: convertFromMessages(request.messages), + messages: request.messages as ModelMessage[], tools, abortSignal: request.signal, }); diff --git a/apps/x/packages/core/src/turns/compose-model-request.ts b/apps/x/packages/core/src/turns/compose-model-request.ts new file mode 100644 index 00000000..ec0a7ef7 --- /dev/null +++ b/apps/x/packages/core/src/turns/compose-model-request.ts @@ -0,0 +1,61 @@ +import type { z } from "zod"; +import { + type ConversationMessage, + type JsonValue, + type ToolDescriptor, + type TurnState, + requestMessagesFor, +} from "@x/shared/dist/turns.js"; +import type { IContextResolver } from "./context-resolver.js"; + +// The exact provider payload for one model call, rebuilt deterministically +// from durable state (turn-runtime-design.md §8.3): +// - systemPrompt and tools come from the resolved agent snapshot (their +// single canonical copy in turn_created), +// - messages are the cross-turn prefix plus every request's reference list +// resolved against the turn's own events, encoded to wire form. +// This is the SAME code path the loop sends through, so the debug view and +// the transmitted bytes cannot diverge. +export interface ComposedModelRequest { + systemPrompt: string; + messages: JsonValue[]; + tools: Array>; + parameters: Record; +} + +export function composeModelRequest( + state: TurnState, + modelCallIndex: number, + // The materialized cross-turn prefix (contextResolver output). Ignored + // for inline-context turns, whose context rides the {context} ref. + resolvedPrefix: Array>, + encode: (messages: Array>) => JsonValue[], +): ComposedModelRequest { + const call = state.modelCalls[modelCallIndex]; + if (!call) { + throw new Error(`no model call at index ${modelCallIndex}`); + } + const prefix = Array.isArray(state.definition.context) ? [] : resolvedPrefix; + const structural = [...prefix]; + for (let index = 0; index <= modelCallIndex; index++) { + structural.push(...requestMessagesFor(state, index)); + } + return { + systemPrompt: state.definition.agent.resolved.systemPrompt, + messages: encode(structural), + tools: state.definition.agent.resolved.tools, + parameters: call.request.parameters, + }; +} + +// Debug/materialization convenience: compose from durable state alone, +// resolving the cross-turn prefix through the context resolver. +export async function materializeModelRequest( + state: TurnState, + modelCallIndex: number, + contextResolver: IContextResolver, + encode: (messages: Array>) => JsonValue[], +): Promise { + const prefix = await contextResolver.resolve(state.definition.context); + return composeModelRequest(state, modelCallIndex, prefix, encode); +} diff --git a/apps/x/packages/core/src/turns/context-resolver.test.ts b/apps/x/packages/core/src/turns/context-resolver.test.ts index 39911786..78cb34bd 100644 --- a/apps/x/packages/core/src/turns/context-resolver.test.ts +++ b/apps/x/packages/core/src/turns/context-resolver.test.ts @@ -57,12 +57,11 @@ function completedTurnLog( ts, modelCallIndex: 0, request: { - systemPrompt: "SYS", ...(Array.isArray(context) ? {} : { contextRef: context }), - messages: Array.isArray(context) - ? [...context, user(inputText)] - : [user(inputText)], - tools: [], + messages: + Array.isArray(context) && context.length > 0 + ? [{ kind: "context" as const }, { kind: "input" as const }] + : [{ kind: "input" as const }], parameters: {}, }, }, @@ -139,9 +138,7 @@ function limitFailedTurnLog(turnId: string): TEvent[] { ts, modelCallIndex: 0, request: { - systemPrompt: "SYS", - messages: [user("do it")], - tools: [echo], + messages: [{ kind: "input" as const }], parameters: {}, }, }, diff --git a/apps/x/packages/core/src/turns/fs-repo.test.ts b/apps/x/packages/core/src/turns/fs-repo.test.ts index 8e44b5b1..b4afe769 100644 --- a/apps/x/packages/core/src/turns/fs-repo.test.ts +++ b/apps/x/packages/core/src/turns/fs-repo.test.ts @@ -41,9 +41,7 @@ function requested(turnId = TURN_ID): z.infer { ts: "2026-07-02T10:00:01Z", modelCallIndex: 0, request: { - systemPrompt: "SYS", - messages: [{ role: "user", content: "hello" }], - tools: [], + messages: [{ kind: "input" }], parameters: {}, }, }; diff --git a/apps/x/packages/core/src/turns/index.ts b/apps/x/packages/core/src/turns/index.ts index e7cbccf3..1ec99055 100644 --- a/apps/x/packages/core/src/turns/index.ts +++ b/apps/x/packages/core/src/turns/index.ts @@ -2,6 +2,7 @@ export * from "./api.js"; export * from "./agent-resolver.js"; export * from "./bus.js"; export * from "./clock.js"; +export * from "./compose-model-request.js"; export * from "./context-resolver.js"; export * from "./fs-repo.js"; export * from "./in-memory-turn-repo.js"; diff --git a/apps/x/packages/core/src/turns/model-registry.ts b/apps/x/packages/core/src/turns/model-registry.ts index d87aa097..d4c83a76 100644 --- a/apps/x/packages/core/src/turns/model-registry.ts +++ b/apps/x/packages/core/src/turns/model-registry.ts @@ -27,7 +27,10 @@ export type LlmStreamEvent = export interface ModelStreamRequest { systemPrompt: string; - messages: Array>; + // Provider wire-form messages: the output of encodeMessages. The loop + // builds these through the shared request composer, so what is sent is + // exactly what composeModelRequest reproduces from the durable log. + messages: JsonValue[]; tools: Array>; parameters: Record; signal: AbortSignal; @@ -35,6 +38,11 @@ export interface ModelStreamRequest { export interface ResolvedModel { descriptor: z.infer; + // Deterministic per-message structural -> wire conversion (e.g. weaving + // userMessageContext into the user text, tool-result enveloping). + encodeMessages( + messages: Array>, + ): JsonValue[]; stream(request: ModelStreamRequest): AsyncIterable; } diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index dc21b55d..3fad9d4b 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -3,10 +3,12 @@ import type { z } from "zod"; import { MODEL_CALL_LIMIT_ERROR_CODE, type JsonValue, + type ModelRequestMessageRef, type ResolvedAgent, type ToolDescriptor, type TurnEvent, type TurnStreamEvent, + reduceTurn, } from "@x/shared/dist/turns.js"; import type { IAgentResolver } from "./agent-resolver.js"; import type { TurnExecution, TurnOutcome } from "./api.js"; @@ -28,6 +30,7 @@ import type { PermissionClassificationBatch, PermissionClassificationInput, } from "./permission.js"; +import { composeModelRequest } from "./compose-model-request.js"; import { TurnRuntime } from "./runtime.js"; import type { IToolRegistry, @@ -153,6 +156,8 @@ class FakeModelRegistry implements IModelRegistry { ): Promise { return { descriptor, + // Identity encoding: tests assert on structural messages directly. + encodeMessages: (messages) => messages as unknown as JsonValue[], stream: (request) => { this.requests.push(request); const call = this.calls[this.next++]; @@ -365,6 +370,15 @@ function typesOf(events: Array<{ type: string }>): string[] { return events.map((e) => e.type); } +// The fake model's identity encoding passes structural messages through. +function sentMessages(request: ModelStreamRequest) { + return request.messages as Array<{ + role: string; + content?: unknown; + toolCallId?: string; + }>; +} + async function persisted( repo: InMemoryTurnRepo, turnId: string, @@ -378,7 +392,7 @@ async function persisted( describe("plain model response (26.1)", () => { it("runs one model step to completion with exact persisted request", async () => { - const { runtime, repo, bus } = makeRuntime({ + const { runtime, repo, models, bus } = makeRuntime({ models: [ respond( { type: "text_delta", delta: "do" }, @@ -408,11 +422,12 @@ describe("plain model response (26.1)", () => { const request = log[1]; expect(request).toMatchObject({ modelCallIndex: 0, - request: { - systemPrompt: "SYS", - messages: [user("hello")], - }, + request: { messages: [{ kind: "input" }] }, }); + // The model received the composed payload: resolved system prompt, + // snapshot tools, encoded messages. + expect(models.requests[0].systemPrompt).toBe("SYS"); + expect(sentMessages(models.requests[0])).toEqual([user("hello")]); // Deltas are streamed but never persisted. expect(events.filter((e) => e.type === "text_delta")).toHaveLength(2); expect(typesOf(log)).not.toContain("text_delta"); @@ -505,19 +520,39 @@ describe("mixed sync and async tools (26.2)", () => { (e) => e.type === "model_call_requested" && e.modelCallIndex === 1, ); expect(secondRequest).toBeDefined(); - const toolMessages = - secondRequest?.type === "model_call_requested" - ? secondRequest.request.messages.filter((m) => m.role === "tool") - : []; - expect(toolMessages.map((m) => (m.role === "tool" ? m.toolCallId : ""))).toEqual( - ["A", "B", "C", "D"], - ); - // The live model call saw the same ordering. expect( - models.requests[1].messages + secondRequest?.type === "model_call_requested" + ? secondRequest.request.messages + : [], + ).toEqual([ + { kind: "assistant", modelCallIndex: 0 }, + { kind: "toolResult", toolCallId: "A" }, + { kind: "toolResult", toolCallId: "B" }, + { kind: "toolResult", toolCallId: "C" }, + { kind: "toolResult", toolCallId: "D" }, + ]); + // The live model call saw the results in source order. + expect( + sentMessages(models.requests[1]) .filter((m) => m.role === "tool") - .map((m) => (m.role === "tool" ? m.toolCallId : "")), + .map((m) => m.toolCallId), ).toEqual(["A", "B", "C", "D"]); + // Byte-for-byte property: the durable file plus the shared composer + // reproduces exactly what the model received. + const state = reduceTurn(log); + for (const index of [0, 1]) { + const composed = composeModelRequest(state, index, [], (m) => m as never); + expect(composed.messages).toEqual(models.requests[index].messages); + expect(composed.systemPrompt).toBe(models.requests[index].systemPrompt); + expect(composed.tools).toEqual(models.requests[index].tools); + } + // Size guard: request events stay reference-sized — the transcript + // duplication this design removes must not creep back. + for (const event of log) { + if (event.type === "model_call_requested") { + expect(JSON.stringify(event).length).toBeLessThan(2048); + } + } }); it("rejects async results for calls that are not pending", async () => { @@ -1129,16 +1164,17 @@ describe("crash recovery (26.7)", () => { }; } - function seedRequested(index: number, messages: Array | ReturnType | { role: "assistant"; content: string } | { role: "tool"; content: string; toolCallId: string; toolName: string }>): z.infer { + function seedRequested( + index: number, + refs: z.infer[] = [{ kind: "input" }], + ): z.infer { return { type: "model_call_requested", turnId: SEED_ID, ts: TS, modelCallIndex: index, request: { - systemPrompt: "SYS", - messages, - tools: defaultAgent.tools, + messages: refs, parameters: {}, }, }; @@ -1169,7 +1205,7 @@ describe("crash recovery (26.7)", () => { it("an unmatched model request is closed as interrupted and re-issued", async () => { const repo = new InMemoryTurnRepo(); - repo.seed([seedCreated(), seedRequested(0, [user("hello")])]); + repo.seed([seedCreated(), seedRequested(0)]); const { runtime, models } = makeRuntime({ repo, models: [respond(completedResp(assistantText("done")))], @@ -1197,7 +1233,7 @@ describe("crash recovery (26.7)", () => { humanAvailable: true, maxModelCalls: 1, }), - seedRequested(0, [user("hello")]), + seedRequested(0), ]); const { runtime } = makeRuntime({ repo }); const { outcome } = await advanceAndSettle(runtime, SEED_ID); @@ -1212,7 +1248,7 @@ describe("crash recovery (26.7)", () => { const batch = assistantCalls(toolCallPart("S", "echo")); repo.seed([ seedCreated(), - seedRequested(0, [user("hello")]), + seedRequested(0), seedCompleted(0, batch), { type: "tool_invocation_requested", @@ -1248,7 +1284,7 @@ describe("crash recovery (26.7)", () => { const batch = assistantCalls(toolCallPart("B", "fetch")); repo.seed([ seedCreated(), - seedRequested(0, [user("hello")]), + seedRequested(0), seedCompleted(0, batch), { type: "tool_invocation_requested", @@ -1288,7 +1324,7 @@ describe("crash recovery (26.7)", () => { const batch = assistantCalls(toolCallPart("S", "echo")); repo.seed([ seedCreated(), - seedRequested(0, [user("hello")]), + seedRequested(0), seedCompleted(0, batch), { type: "tool_invocation_requested", @@ -1317,7 +1353,7 @@ describe("crash recovery (26.7)", () => { const { outcome } = await advanceAndSettle(runtime, SEED_ID); expect(outcome?.status).toBe("completed"); expect( - models.requests[0].messages.filter((m) => m.role === "tool"), + sentMessages(models.requests[0]).filter((m) => m.role === "tool"), ).toHaveLength(1); }); @@ -1326,7 +1362,7 @@ describe("crash recovery (26.7)", () => { const batch = assistantCalls(toolCallPart("P", "echo")); repo.seed([ seedCreated(), - seedRequested(0, [user("hello")]), + seedRequested(0), seedCompleted(0, batch), { type: "tool_permission_required", diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index f8682f1c..fd7caaf4 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -6,6 +6,7 @@ import { type JsonValue, type ModelCallFailed, type ModelRequest, + type ModelRequestMessageRef, type ToolCallState, type ToolDescriptor, type ToolInvocationRequested, @@ -21,7 +22,6 @@ import { outstandingAsyncTools, outstandingPermissions, reduceTurn, - turnTranscript, } from "@x/shared/dist/turns.js"; import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import type { IAgentResolver } from "./agent-resolver.js"; @@ -37,6 +37,7 @@ import { } from "./api.js"; import type { ITurnLifecycleBus } from "./bus.js"; import type { IClock } from "./clock.js"; +import { composeModelRequest } from "./compose-model-request.js"; import type { IContextResolver } from "./context-resolver.js"; import type { IModelRegistry, @@ -847,17 +848,39 @@ class TurnAdvance { // §8.3/§18h–l: one model step. The durable request barrier precedes the // provider call; step events persist before the next provider read; - // deltas bypass storage. Returns an outcome only on failure/cancel. + // deltas bypass storage. The request records only REFERENCES to what is + // new since the previous call; the payload actually sent is built by the + // shared composer over the durable state, so file + composer reproduce + // the wire bytes exactly. Returns an outcome only on failure/cancel. private async runModelStep(): Promise { const index = this.state.modelCalls.length; - const transcript = turnTranscript(this.state); const context = this.definition.context; const isRef = !Array.isArray(context); + let refs: Array>; + if (index === 0) { + refs = + !isRef && context.length > 0 + ? [{ kind: "context" }, { kind: "input" }] + : [{ kind: "input" }]; + } else { + const previous = this.state.modelCalls[index - 1]; + refs = + previous.response !== undefined + ? [ + { kind: "assistant", modelCallIndex: previous.index }, + ...this.state.toolCalls + .filter((tc) => tc.modelCallIndex === previous.index) + .sort((a, b) => a.order - b.order) + .map((tc) => ({ + kind: "toolResult" as const, + toolCallId: tc.toolCallId, + })), + ] + : []; // re-issue after an interrupted call adds nothing new + } const request: z.infer = { - systemPrompt: this.definition.agent.resolved.systemPrompt, - ...(isRef ? { contextRef: context } : {}), - messages: isRef ? transcript : [...context, ...transcript], - tools: this.definition.agent.resolved.tools, + ...(isRef && index === 0 ? { contextRef: context } : {}), + messages: refs, parameters: {}, }; await this.append({ @@ -868,14 +891,21 @@ class TurnAdvance { request, }); + const composed = composeModelRequest( + this.state, + index, + this.resolvedContext, + (messages) => this.model.encodeMessages(messages), + ); + let completion: Extract | null = null; try { for await (const event of this.model.stream({ - systemPrompt: request.systemPrompt, - messages: [...this.resolvedContext, ...transcript], - tools: request.tools, - parameters: request.parameters, + systemPrompt: composed.systemPrompt, + messages: composed.messages, + tools: composed.tools, + parameters: composed.parameters, signal: this.signal, })) { switch (event.type) { diff --git a/apps/x/packages/shared/src/turns.test.ts b/apps/x/packages/shared/src/turns.test.ts index 3c77faf5..4f5f4d11 100644 --- a/apps/x/packages/shared/src/turns.test.ts +++ b/apps/x/packages/shared/src/turns.test.ts @@ -118,9 +118,7 @@ function requested( ts: TS, modelCallIndex: index, request: { - systemPrompt: "SYS", messages, - tools: [echoTool, fetchTool], parameters: {}, ...requestOverrides, }, @@ -348,13 +346,13 @@ function syncToolSequence(): TEvent[] { const call0 = assistantCalls(toolCallPart("tc1", "echo")); return [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "allow"), invocation("tc1"), result("tc1", "echo"), - requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]), + requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), completed(1, assistantText("done")), turnCompletedEv(), ]; @@ -395,7 +393,7 @@ describe("plain completion", () => { it("reduces a plain model response to a completed turn", () => { const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), stepEvent(0), completed(0, assistantText("done")), turnCompletedEv(), @@ -420,7 +418,7 @@ describe("plain completion", () => { it("leaves usage fields undefined when never reported", () => { const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantText("a"), { inputTokens: 5 }), ]); expect(state.usage).toEqual({ inputTokens: 5 }); @@ -436,7 +434,7 @@ describe("tool execution", () => { ); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), ]); expect(state.toolCalls).toHaveLength(2); @@ -459,7 +457,7 @@ describe("tool execution", () => { it("leaves identity undefined for tools missing from the agent snapshot", () => { const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantCalls(toolCallPart("x1", "unknown-tool"))), result("x1", "unknown-tool", "runtime", "No such tool", true), ]); @@ -482,7 +480,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created({ config: { autoPermission: true, humanAvailable: true, maxModelCalls: 20 } }), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permClassified("tc1", "allow"), @@ -499,7 +497,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permClassificationFailed(["tc1"]), @@ -514,7 +512,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), progress("tc1"), @@ -528,7 +526,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "deny"), @@ -543,7 +541,7 @@ describe("context references", () => { it("accepts a referenced context with matching contextRef on requests", () => { const state = reduceTurn([ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [user("hello")], { + requested(0, [{ kind: "input" }], { contextRef: { previousTurnId: PREV_TURN_ID }, }), completed(0, assistantText("done")), @@ -556,7 +554,7 @@ describe("context references", () => { expectCorruption( [ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), ], /contextRef inconsistent/, ); @@ -566,7 +564,7 @@ describe("context references", () => { expectCorruption( [ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [user("hello")], { + requested(0, [{ kind: "input" }], { contextRef: { previousTurnId: "some-other-turn" }, }), ], @@ -578,7 +576,7 @@ describe("context references", () => { expectCorruption( [ created(), - requested(0, [user("hello")], { + requested(0, [{ kind: "input" }], { contextRef: { previousTurnId: PREV_TURN_ID }, }), ], @@ -586,7 +584,7 @@ describe("context references", () => { ); }); - it("ignores tool messages inside an inline context prefix when checking order", () => { + it("requires a {context} ref before {input} when inline context is nonempty", () => { const context = [ user("earlier"), assistantCalls(toolCallPart("old1", "echo")), @@ -594,11 +592,38 @@ describe("context references", () => { ]; const state = reduceTurn([ created({ context }), - requested(0, [...context, user("hello")]), + requested(0, [{ kind: "context" }, { kind: "input" }]), completed(0, assistantText("done")), turnCompletedEv(), ]); expect(deriveTurnStatus(state)).toBe("completed"); + expectCorruption( + [created({ context }), requested(0, [{ kind: "input" }])], + /references do not match/, + ); + }); + + it("rejects a contextRef on a non-initial model call", () => { + expectCorruption( + [ + created({ context: { previousTurnId: PREV_TURN_ID } }), + requested(0, [{ kind: "input" }], { + contextRef: { previousTurnId: PREV_TURN_ID }, + }), + completed(0, assistantCalls(toolCallPart("tc1", "echo"))), + invocation("tc1"), + result("tc1", "echo"), + requested( + 1, + [ + { kind: "assistant", modelCallIndex: 0 }, + { kind: "toolResult", toolCallId: "tc1" }, + ], + { contextRef: { previousTurnId: PREV_TURN_ID } }, + ), + ], + /contextRef on a non-initial model call/, + ); }); }); @@ -610,7 +635,7 @@ describe("suspension", () => { ); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), invocation("a1", fetchTool), @@ -628,7 +653,7 @@ describe("suspension", () => { ); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), invocation("a1", fetchTool), @@ -648,7 +673,7 @@ describe("suspension", () => { ); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("a1", fetchTool), invocation("a2", fetchTool), @@ -657,10 +682,9 @@ describe("suspension", () => { suspendedEv([], [{ id: "a1", tool: fetchTool }]), result("a1", "fetch", "async", "first"), requested(1, [ - user("hello"), - call0, - toolMsg("a1", "fetch", "first"), - toolMsg("a2", "fetch", "second"), + { kind: "assistant", modelCallIndex: 0 }, + { kind: "toolResult", toolCallId: "a1" }, + { kind: "toolResult", toolCallId: "a2" }, ]), completed(1, assistantText("done")), turnCompletedEv(), @@ -673,7 +697,7 @@ describe("suspension", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "allow"), @@ -693,7 +717,7 @@ describe("suspension", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), invocation("a1", fetchTool), @@ -707,7 +731,7 @@ describe("suspension", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), suspendedEv([{ id: "tc1", name: "echo" }], []), ], /suspension while a model call is unsettled/, @@ -719,9 +743,9 @@ describe("recovery-shaped histories", () => { it("accepts an interrupted model call closed and re-issued (§23 fix)", () => { const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), callFailed(0, "interrupted by process restart"), - requested(1, [user("hello")]), + requested(1, []), completed(1, assistantText("done")), turnCompletedEv(), ]); @@ -736,9 +760,9 @@ describe("recovery-shaped histories", () => { created({ config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 }, }), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), callFailed(0, "interrupted"), - requested(1, [user("hello")]), + requested(1, []), ], /exceeds maxModelCalls/, ); @@ -748,7 +772,7 @@ describe("recovery-shaped histories", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result( @@ -758,7 +782,7 @@ describe("recovery-shaped histories", () => { "Tool execution was interrupted; its outcome is unknown and it was not retried.", true, ), - requested(1, [user("hello"), call0, toolMsg("tc1", "echo", "…")]), + requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), completed(1, assistantText("done")), turnCompletedEv(), ]); @@ -770,7 +794,7 @@ describe("recovery-shaped histories", () => { const call0 = assistantCalls(toolCallPart("a1", "fetch")); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("a1", fetchTool), suspendedEv([], [{ id: "a1", tool: fetchTool }]), @@ -784,7 +808,7 @@ describe("recovery-shaped histories", () => { it("accepts a live model failure closing the turn", () => { const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), callFailed(0), turnFailedEv("provider exploded"), ]); @@ -797,7 +821,7 @@ describe("recovery-shaped histories", () => { created({ config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 }, }), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -816,7 +840,7 @@ describe("invariants", () => { }); it("rejects a log not starting with turn_created", () => { - expectCorruption([requested(0, [user("hello")])], /first event must be turn_created/); + expectCorruption([requested(0, [{ kind: "input" }])], /first event must be turn_created/); }); it("rejects duplicate turn_created", () => { @@ -825,7 +849,7 @@ describe("invariants", () => { it("rejects mismatched turn ids", () => { expectCorruption( - [created(), { ...requested(0, [user("hello")]), turnId: "other" }], + [created(), { ...requested(0, [{ kind: "input" }]), turnId: "other" }], /does not match turn/, ); }); @@ -849,7 +873,7 @@ describe("invariants", () => { it("rejects out-of-order model call indices", () => { expectCorruption( - [created(), requested(1, [user("hello")])], + [created(), requested(1, [])], /out of order/, ); }); @@ -858,9 +882,9 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), ], /out of order/, ); @@ -868,7 +892,7 @@ describe("invariants", () => { it("rejects concurrent unresolved model requests", () => { expectCorruption( - [created(), requested(0, [user("hello")]), requested(1, [user("hello")])], + [created(), requested(0, [{ kind: "input" }]), requested(1, [])], /concurrent unresolved model call requests/, ); }); @@ -888,7 +912,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), completed(0, assistantText("b")), ], @@ -900,7 +924,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), callFailed(0), completed(0, assistantText("a")), ], @@ -913,9 +937,9 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), - requested(1, [user("hello"), call0]), + requested(1, [{ kind: "assistant", modelCallIndex: 0 }]), ], /while tool calls are unresolved/, ); @@ -928,11 +952,11 @@ describe("invariants", () => { created({ config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 }, }), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), - requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]), + requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), ], /exceeds maxModelCalls/, ); @@ -942,7 +966,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed( 0, assistantCalls(toolCallPart("tc1", "echo"), toolCallPart("tc1", "echo")), @@ -957,18 +981,18 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), - requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]), + requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), completed(1, assistantCalls(toolCallPart("tc1", "echo"))), ], /duplicate tool call id/, ); }); - it("rejects tool-result ordering that differs from original call order", () => { + it("rejects request references whose tool-result order differs from the batch", () => { const call0 = assistantCalls( toolCallPart("tc1", "echo"), toolCallPart("tc2", "echo"), @@ -976,26 +1000,25 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), invocation("tc2"), result("tc2", "echo"), requested(1, [ - user("hello"), - call0, - toolMsg("tc2", "echo"), - toolMsg("tc1", "echo"), + { kind: "assistant", modelCallIndex: 0 }, + { kind: "toolResult", toolCallId: "tc2" }, + { kind: "toolResult", toolCallId: "tc1" }, ]), ], - /tool-result order differs/, + /references do not match/, ); }); it("rejects permission records targeting unknown tool calls", () => { expectCorruption( - [created(), requested(0, [user("hello")]), completed(0, assistantText("a")), permRequired("ghost", "echo")], + [created(), requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), permRequired("ghost", "echo")], /unknown tool call/, ); }); @@ -1005,7 +1028,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permRequired("tc1", "echo"), @@ -1019,7 +1042,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permClassified("tc1", "allow"), ], @@ -1032,7 +1055,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permResolved("tc1", "allow"), ], @@ -1045,7 +1068,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "allow"), @@ -1060,7 +1083,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), invocation("tc1"), @@ -1074,7 +1097,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "deny"), @@ -1089,7 +1112,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), invocation("tc1"), @@ -1103,7 +1126,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1", echoTool, { execution: "async" }), ], @@ -1114,7 +1137,7 @@ describe("invariants", () => { it("rejects progress without invocation", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [user("hello")]), completed(0, call0), progress("tc1")], + [created(), requested(0, [{ kind: "input" }]), completed(0, call0), progress("tc1")], /progress without invocation/, ); }); @@ -1124,7 +1147,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -1139,7 +1162,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -1152,7 +1175,7 @@ describe("invariants", () => { it("rejects sync-sourced results without invocation", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [user("hello")]), completed(0, call0), result("tc1", "echo", "sync")], + [created(), requested(0, [{ kind: "input" }]), completed(0, call0), result("tc1", "echo", "sync")], /sync tool result without invocation/, ); }); @@ -1162,7 +1185,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo", "async"), @@ -1176,7 +1199,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), stepEvent(0), ], @@ -1187,7 +1210,7 @@ describe("invariants", () => { it("rejects completion while tool calls remain unresolved", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [user("hello")]), completed(0, call0), turnCompletedEv()], + [created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnCompletedEv()], /completion while tool calls lack terminal results/, ); }); @@ -1197,7 +1220,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -1214,14 +1237,14 @@ describe("invariants", () => { it("rejects terminal failure while tool calls remain unresolved", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [user("hello")]), completed(0, call0), turnFailedEv()], + [created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnFailedEv()], /failure while tool calls lack terminal results/, ); }); it("rejects terminal events while a model call is unsettled", () => { expectCorruption( - [created(), requested(0, [user("hello")]), turnCancelledEv()], + [created(), requested(0, [{ kind: "input" }]), turnCancelledEv()], /cancellation while a model call is unsettled/, ); }); @@ -1229,7 +1252,7 @@ describe("invariants", () => { it("rejects any event after a terminal event", () => { const base = [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), ]; for (const terminal of [turnCompletedEv(), turnFailedEv(), turnCancelledEv()]) { @@ -1244,7 +1267,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), turnCompletedEv(), turnFailedEv(), @@ -1259,14 +1282,14 @@ describe("derivations", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const pending = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), permRequired("tc1", "echo"), suspendedEv([{ id: "tc1", name: "echo" }], []), ]); expect(deriveTurnStatus(pending)).toBe("suspended"); - const idle = reduceTurn([created(), requested(0, [user("hello")]), completed(0, call0)]); + const idle = reduceTurn([created(), requested(0, [{ kind: "input" }]), completed(0, call0)]); expect(deriveTurnStatus(idle)).toBe("idle"); }); @@ -1277,17 +1300,16 @@ describe("derivations", () => { ); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), invocation("tc1"), invocation("tc2"), result("tc2", "echo", "sync", { n: 2 }), result("tc1", "echo", "sync", "plain text"), requested(1, [ - user("hello"), - call0, - toolMsg("tc1", "echo", "plain text"), - toolMsg("tc2", "echo", '{"n":2}'), + { kind: "assistant", modelCallIndex: 0 }, + { kind: "toolResult", toolCallId: "tc1" }, + { kind: "toolResult", toolCallId: "tc2" }, ]), completed(1, assistantText("done")), turnCompletedEv(), @@ -1304,9 +1326,9 @@ describe("derivations", () => { it("omits failed model calls from the transcript", () => { const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), callFailed(0, "interrupted"), - requested(1, [user("hello")]), + requested(1, []), completed(1, assistantText("done")), turnCompletedEv(), ]); @@ -1316,7 +1338,7 @@ describe("derivations", () => { it("transcript excludes the context prefix", () => { const state = reduceTurn([ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [user("hello")], { + requested(0, [{ kind: "input" }], { contextRef: { previousTurnId: PREV_TURN_ID }, }), completed(0, assistantText("done")), @@ -1330,7 +1352,7 @@ describe("derivations", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [user("hello")]), + requested(0, [{ kind: "input" }]), completed(0, call0), ]); expect(() => turnTranscript(state)).toThrowError(/unresolved/); diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index b93ff3ec..3b71b0d3 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -119,16 +119,30 @@ export const TurnCreated = z.object({ }), }); -// `messages` contains current-turn messages only; when the turn's context is -// a reference, `contextRef` repeats it and the resolved prefix is implicit -// (deterministically materializable because referenced turns are immutable). -// When context is inline, `contextRef` is absent and `messages` begins with -// the inline context. +// A model request is a list of REFERENCES into the turn's own events; every +// referenced byte exists exactly once in the file. The request records only +// what is NEW since the previous model call: +// call 0: [{context}?, {input}] (context only when inline and nonempty; +// cross-turn prefixes ride contextRef) +// call N: [{assistant: N-1}, ...that batch's toolResults in source order] +// re-issue after an interrupted call: [] +// The system prompt and tool set are NOT repeated here — they are byte- +// identical to turn_created.agent.resolved by construction. The exact +// provider payload is rebuilt deterministically by the request composer +// (core), which is the same code path the loop sends through. +export const ModelRequestMessageRef = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("context") }), + z.object({ kind: z.literal("input") }), + z.object({ + kind: z.literal("assistant"), + modelCallIndex: z.number().int().nonnegative(), + }), + z.object({ kind: z.literal("toolResult"), toolCallId: z.string() }), +]); + export const ModelRequest = z.object({ - systemPrompt: z.string(), contextRef: TurnContextRef.optional(), - messages: z.array(ConversationMessage), - tools: z.array(ToolDescriptor), + messages: z.array(ModelRequestMessageRef), parameters: z.record(z.string(), z.json()), }); @@ -491,32 +505,44 @@ function applyModelCallRequested( } const context = state.definition.context; - if (isContextRef(context)) { - if (event.request.contextRef?.previousTurnId !== context.previousTurnId) { - fail("model request contextRef inconsistent with turn context"); + let expectedRefs: Array>; + if (event.modelCallIndex === 0) { + if (isContextRef(context)) { + if (event.request.contextRef?.previousTurnId !== context.previousTurnId) { + fail("model request contextRef inconsistent with turn context"); + } + expectedRefs = [{ kind: "input" }]; + } else { + if (event.request.contextRef !== undefined) { + fail("model request has contextRef but turn context is inline"); + } + expectedRefs = + context.length > 0 + ? [{ kind: "context" }, { kind: "input" }] + : [{ kind: "input" }]; } - } else if (event.request.contextRef !== undefined) { - fail("model request has contextRef but turn context is inline"); + } else { + if (event.request.contextRef !== undefined) { + fail("model request has contextRef on a non-initial model call"); + } + const previous = state.modelCalls[event.modelCallIndex - 1]; + expectedRefs = + previous.response !== undefined + ? [ + { kind: "assistant", modelCallIndex: previous.index }, + ...batchToolCalls(state, previous.index).map((tc) => ({ + kind: "toolResult" as const, + toolCallId: tc.toolCallId, + })), + ] + : []; // re-issue after an interrupted call adds nothing new } - - // Model-facing tool-result order must match original call order: the tool - // messages in the request (past any inline context prefix) must be the - // concatenated batches of prior completed calls, in source order. - const prefixLength = isContextRef(context) ? 0 : context.length; - const toolMessageIds = event.request.messages - .slice(prefixLength) - .filter((m) => m.role === "tool") - .map((m) => m.toolCallId); - const expectedIds = state.modelCalls - .filter((call) => call.response !== undefined) - .flatMap((call) => - batchToolCalls(state, call.index).map((tc) => tc.toolCallId), + if (JSON.stringify(event.request.messages) !== JSON.stringify(expectedRefs)) { + fail( + `model request references do not match the transcript: expected ${JSON.stringify( + expectedRefs, + )}, got ${JSON.stringify(event.request.messages)}`, ); - if ( - toolMessageIds.length !== expectedIds.length || - toolMessageIds.some((id, i) => id !== expectedIds[i]) - ) { - fail("model request tool-result order differs from original call order"); } state.modelCalls.push({ @@ -932,6 +958,69 @@ function toolResultContent(result: z.infer): string { return typeof output === "string" ? output : JSON.stringify(output); } +// The canonical model-facing tool message for a resolved tool call. +export function toolResultMessage( + toolCall: ToolCallState, +): z.infer { + if (!toolCall.result) { + throw new Error( + `tool call ${toolCall.toolCallId} has no terminal result`, + ); + } + return { + role: "tool", + content: toolResultContent(toolCall.result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }; +} + +// Resolves one model call's request references to structural messages (the +// NEW portion that call added relative to the previous one). Concatenating +// calls 0..N yields the full current-turn conversation for call N; the +// request composer (core) prepends the resolved cross-turn prefix and +// encodes to the provider wire form. +export function requestMessagesFor( + state: TurnState, + modelCallIndex: number, +): Array> { + const call = state.modelCalls[modelCallIndex]; + if (!call) { + throw new Error(`no model call at index ${modelCallIndex}`); + } + return call.request.messages.flatMap((ref) => { + switch (ref.kind) { + case "context": { + const context = state.definition.context; + if (!Array.isArray(context)) { + throw new Error("context ref on a turn without inline context"); + } + return context; + } + case "input": + return [state.definition.input]; + case "assistant": { + const response = state.modelCalls[ref.modelCallIndex]?.response; + if (response === undefined) { + throw new Error( + `assistant ref to unsettled model call ${ref.modelCallIndex}`, + ); + } + return [response]; + } + case "toolResult": { + const toolCall = state.toolCalls.find( + (tc) => tc.toolCallId === ref.toolCallId, + ); + if (!toolCall) { + throw new Error(`toolResult ref to unknown call ${ref.toolCallId}`); + } + return [toolResultMessage(toolCall)]; + } + } + }); +} + // The messages this turn contributed to the conversation: its input plus, per // completed model call, the assistant response and its tool results in source // order. Failed model calls contribute nothing. The turn's context prefix is @@ -955,12 +1044,7 @@ export function turnTranscript( `turnTranscript requires terminal tool results; ${toolCall.toolCallId} is unresolved`, ); } - messages.push({ - role: "tool", - content: toolResultContent(toolCall.result), - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - }); + messages.push(toolResultMessage(toolCall)); } } return messages;