diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 9a0ca542..88290b7e 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -479,11 +479,12 @@ 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 +// Compact string refs, so raw JSONL reads naturally: +// "context" the inline context block +// "input" the turn's user message +// "assistant:" → that model_call_completed's message +// "toolResult:" → that tool_result +type ModelRequestMessageRef = string; interface ModelRequest { contextRef?: { previousTurnId: string }; // call 0 only (cross-turn prefix) @@ -503,10 +504,11 @@ agent model call in the turn. `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}]` +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 +`contextRef`); call N is `["assistant:N-1", …that batch's +"toolResult:" refs 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. diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 08c2644d..8ba46048 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -8,7 +8,8 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "dev": "tsc -w -p tsconfig.build.json", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "inspect-turn": "node dist/turns/inspect-cli.js" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "^0.39.0", diff --git a/apps/x/packages/core/src/sessions/sessions.test.ts b/apps/x/packages/core/src/sessions/sessions.test.ts index fccdb948..6ff498d8 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: { messages: [{ kind: 'input' as const }], parameters: {} }, + request: { messages: ['input'], parameters: {} }, }; switch (status) { case "idle": 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 78cb34bd..6ccfe671 100644 --- a/apps/x/packages/core/src/turns/context-resolver.test.ts +++ b/apps/x/packages/core/src/turns/context-resolver.test.ts @@ -60,8 +60,8 @@ function completedTurnLog( ...(Array.isArray(context) ? {} : { contextRef: context }), messages: Array.isArray(context) && context.length > 0 - ? [{ kind: "context" as const }, { kind: "input" as const }] - : [{ kind: "input" as const }], + ? ["context", "input"] + : ["input"], parameters: {}, }, }, @@ -138,7 +138,7 @@ function limitFailedTurnLog(turnId: string): TEvent[] { ts, modelCallIndex: 0, request: { - messages: [{ kind: "input" as const }], + messages: ["input"], 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 b4afe769..583ea330 100644 --- a/apps/x/packages/core/src/turns/fs-repo.test.ts +++ b/apps/x/packages/core/src/turns/fs-repo.test.ts @@ -41,7 +41,7 @@ function requested(turnId = TURN_ID): z.infer { ts: "2026-07-02T10:00:01Z", modelCallIndex: 0, request: { - messages: [{ kind: "input" }], + messages: ["input"], parameters: {}, }, }; diff --git a/apps/x/packages/core/src/turns/inspect-cli.ts b/apps/x/packages/core/src/turns/inspect-cli.ts new file mode 100644 index 00000000..3329d99d --- /dev/null +++ b/apps/x/packages/core/src/turns/inspect-cli.ts @@ -0,0 +1,105 @@ +// Turn inspector: prints, per model call, the EXACT provider payload the +// loop sent — rebuilt from the durable file by the same composer the loop +// transmits through (compose-model-request.ts). This is where the woven +// wire-form messages (user-message context, attachments, tool-result +// envelopes) are visible; the file itself stores only structural facts and +// references. +// +// Usage: +// npm run inspect-turn -- [modelCallIndex] [--full] +// +// --full prints the entire system prompt and untruncated message contents. +import fs from "node:fs"; +import path from "node:path"; +import { + reduceTurn, + type JsonValue, + type TurnEvent, +} from "@x/shared/dist/turns.js"; +import type { z } from "zod"; +import { convertFromMessages } from "../agents/runtime.js"; +import { WorkDir } from "../config/config.js"; +import { composeModelRequest } from "./compose-model-request.js"; +import { TurnRepoContextResolver } from "./context-resolver.js"; +import { FSTurnRepo } from "./fs-repo.js"; + +function usage(): never { + console.error( + "usage: inspect-turn [modelCallIndex] [--full]", + ); + process.exit(1); +} + +function clip(text: string, full: boolean, limit = 400): string { + if (full || text.length <= limit) return text; + return `${text.slice(0, limit)}… [${text.length} chars total; pass --full]`; +} + +async function main(): Promise { + const args = process.argv.slice(2).filter((a) => a !== "--full"); + const full = process.argv.includes("--full"); + const target = args[0]; + if (!target) usage(); + const onlyIndex = args[1] !== undefined ? Number(args[1]) : undefined; + + const turnsRootDir = path.join(WorkDir, "storage", "turns"); + const repo = new FSTurnRepo({ turnsRootDir }); + const turnId = target.endsWith(".jsonl") + ? path.basename(target, ".jsonl") + : target; + + let events: Array>; + if (target.endsWith(".jsonl") && fs.existsSync(target)) { + // Direct path: parse in place (still strict via the reducer below). + events = fs + .readFileSync(target, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as z.infer); + } else { + events = await repo.read(turnId); + } + const state = reduceTurn(events); + const resolver = new TurnRepoContextResolver({ turnRepo: repo }); + const prefix = await resolver.resolve(state.definition.context); + const encode = (messages: Parameters[0]) => + convertFromMessages(messages) as unknown as JsonValue[]; + + console.log(`turn ${turnId}`); + console.log( + `agent ${state.definition.agent.resolved.agentId} model ${state.definition.agent.resolved.model.provider}/${state.definition.agent.resolved.model.model} calls ${state.modelCalls.length}`, + ); + + for (const call of state.modelCalls) { + if (onlyIndex !== undefined && call.index !== onlyIndex) continue; + const composed = composeModelRequest(state, call.index, prefix, encode); + console.log(`\n━━ model call ${call.index} ━━ (as sent to the provider)`); + console.log(`system (${composed.systemPrompt.length} chars): ${clip(composed.systemPrompt, full)}`); + console.log( + `tools (${composed.tools.length}): ${composed.tools.map((t) => t.name).join(", ")}`, + ); + console.log(`messages (${composed.messages.length}):`); + for (const message of composed.messages) { + const m = message as { role?: string; content?: unknown }; + const content = + typeof m.content === "string" + ? m.content + : JSON.stringify(m.content); + console.log(` [${m.role}] ${clip(content, full)}`); + } + if (call.error !== undefined) { + console.log(` → failed: ${call.error}`); + } else if (call.response !== undefined) { + const response = + typeof call.response.content === "string" + ? call.response.content + : JSON.stringify(call.response.content); + console.log(` → response (${call.finishReason}): ${clip(response, full)}`); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index 3fad9d4b..1d2671ed 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -3,7 +3,6 @@ import type { z } from "zod"; import { MODEL_CALL_LIMIT_ERROR_CODE, type JsonValue, - type ModelRequestMessageRef, type ResolvedAgent, type ToolDescriptor, type TurnEvent, @@ -422,7 +421,7 @@ describe("plain model response (26.1)", () => { const request = log[1]; expect(request).toMatchObject({ modelCallIndex: 0, - request: { messages: [{ kind: "input" }] }, + request: { messages: ["input"] }, }); // The model received the composed payload: resolved system prompt, // snapshot tools, encoded messages. @@ -525,11 +524,11 @@ describe("mixed sync and async tools (26.2)", () => { ? secondRequest.request.messages : [], ).toEqual([ - { kind: "assistant", modelCallIndex: 0 }, - { kind: "toolResult", toolCallId: "A" }, - { kind: "toolResult", toolCallId: "B" }, - { kind: "toolResult", toolCallId: "C" }, - { kind: "toolResult", toolCallId: "D" }, + "assistant:0", + "toolResult:A", + "toolResult:B", + "toolResult:C", + "toolResult:D", ]); // The live model call saw the results in source order. expect( @@ -1166,7 +1165,7 @@ describe("crash recovery (26.7)", () => { function seedRequested( index: number, - refs: z.infer[] = [{ kind: "input" }], + refs: string[] = ["input"], ): z.infer { return { type: "model_call_requested", diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index fd7caaf4..d82e575a 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -6,7 +6,8 @@ import { type JsonValue, type ModelCallFailed, type ModelRequest, - type ModelRequestMessageRef, + assistantRef, + toolResultRef, type ToolCallState, type ToolDescriptor, type ToolInvocationRequested, @@ -856,25 +857,22 @@ class TurnAdvance { const index = this.state.modelCalls.length; const context = this.definition.context; const isRef = !Array.isArray(context); - let refs: Array>; + let refs: string[]; if (index === 0) { refs = !isRef && context.length > 0 - ? [{ kind: "context" }, { kind: "input" }] - : [{ kind: "input" }]; + ? ["context", "input"] + : ["input"]; } else { const previous = this.state.modelCalls[index - 1]; refs = previous.response !== undefined ? [ - { kind: "assistant", modelCallIndex: previous.index }, + assistantRef(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, - })), + .map((tc) => toolResultRef(tc.toolCallId)), ] : []; // re-issue after an interrupted call adds nothing new } diff --git a/apps/x/packages/shared/src/turns.test.ts b/apps/x/packages/shared/src/turns.test.ts index 4f5f4d11..844d76b2 100644 --- a/apps/x/packages/shared/src/turns.test.ts +++ b/apps/x/packages/shared/src/turns.test.ts @@ -346,13 +346,13 @@ function syncToolSequence(): TEvent[] { const call0 = assistantCalls(toolCallPart("tc1", "echo")); return [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "allow"), invocation("tc1"), result("tc1", "echo"), - requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), + requested(1, ["assistant:0", "toolResult:tc1"]), completed(1, assistantText("done")), turnCompletedEv(), ]; @@ -393,7 +393,7 @@ describe("plain completion", () => { it("reduces a plain model response to a completed turn", () => { const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), stepEvent(0), completed(0, assistantText("done")), turnCompletedEv(), @@ -418,7 +418,7 @@ describe("plain completion", () => { it("leaves usage fields undefined when never reported", () => { const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantText("a"), { inputTokens: 5 }), ]); expect(state.usage).toEqual({ inputTokens: 5 }); @@ -434,7 +434,7 @@ describe("tool execution", () => { ); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), ]); expect(state.toolCalls).toHaveLength(2); @@ -457,7 +457,7 @@ describe("tool execution", () => { it("leaves identity undefined for tools missing from the agent snapshot", () => { const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantCalls(toolCallPart("x1", "unknown-tool"))), result("x1", "unknown-tool", "runtime", "No such tool", true), ]); @@ -480,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, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permClassified("tc1", "allow"), @@ -497,7 +497,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permClassificationFailed(["tc1"]), @@ -512,7 +512,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), progress("tc1"), @@ -526,7 +526,7 @@ describe("tool execution", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "deny"), @@ -541,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, [{ kind: "input" }], { + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID }, }), completed(0, assistantText("done")), @@ -554,7 +554,7 @@ describe("context references", () => { expectCorruption( [ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), ], /contextRef inconsistent/, ); @@ -564,7 +564,7 @@ describe("context references", () => { expectCorruption( [ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [{ kind: "input" }], { + requested(0, ["input"], { contextRef: { previousTurnId: "some-other-turn" }, }), ], @@ -576,7 +576,7 @@ describe("context references", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }], { + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID }, }), ], @@ -592,13 +592,13 @@ describe("context references", () => { ]; const state = reduceTurn([ created({ context }), - requested(0, [{ kind: "context" }, { kind: "input" }]), + requested(0, ["context", "input"]), completed(0, assistantText("done")), turnCompletedEv(), ]); expect(deriveTurnStatus(state)).toBe("completed"); expectCorruption( - [created({ context }), requested(0, [{ kind: "input" }])], + [created({ context }), requested(0, ["input"])], /references do not match/, ); }); @@ -607,7 +607,7 @@ describe("context references", () => { expectCorruption( [ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [{ kind: "input" }], { + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID }, }), completed(0, assistantCalls(toolCallPart("tc1", "echo"))), @@ -616,8 +616,8 @@ describe("context references", () => { requested( 1, [ - { kind: "assistant", modelCallIndex: 0 }, - { kind: "toolResult", toolCallId: "tc1" }, + "assistant:0", + "toolResult:tc1", ], { contextRef: { previousTurnId: PREV_TURN_ID } }, ), @@ -635,7 +635,7 @@ describe("suspension", () => { ); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), invocation("a1", fetchTool), @@ -653,7 +653,7 @@ describe("suspension", () => { ); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), invocation("a1", fetchTool), @@ -673,7 +673,7 @@ describe("suspension", () => { ); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("a1", fetchTool), invocation("a2", fetchTool), @@ -682,9 +682,9 @@ describe("suspension", () => { suspendedEv([], [{ id: "a1", tool: fetchTool }]), result("a1", "fetch", "async", "first"), requested(1, [ - { kind: "assistant", modelCallIndex: 0 }, - { kind: "toolResult", toolCallId: "a1" }, - { kind: "toolResult", toolCallId: "a2" }, + "assistant:0", + "toolResult:a1", + "toolResult:a2", ]), completed(1, assistantText("done")), turnCompletedEv(), @@ -697,7 +697,7 @@ describe("suspension", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "allow"), @@ -717,7 +717,7 @@ describe("suspension", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), invocation("a1", fetchTool), @@ -731,7 +731,7 @@ describe("suspension", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), suspendedEv([{ id: "tc1", name: "echo" }], []), ], /suspension while a model call is unsettled/, @@ -743,7 +743,7 @@ describe("recovery-shaped histories", () => { it("accepts an interrupted model call closed and re-issued (§23 fix)", () => { const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), callFailed(0, "interrupted by process restart"), requested(1, []), completed(1, assistantText("done")), @@ -760,7 +760,7 @@ describe("recovery-shaped histories", () => { created({ config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 }, }), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), callFailed(0, "interrupted"), requested(1, []), ], @@ -772,7 +772,7 @@ describe("recovery-shaped histories", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result( @@ -782,7 +782,7 @@ describe("recovery-shaped histories", () => { "Tool execution was interrupted; its outcome is unknown and it was not retried.", true, ), - requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), + requested(1, ["assistant:0", "toolResult:tc1"]), completed(1, assistantText("done")), turnCompletedEv(), ]); @@ -794,7 +794,7 @@ describe("recovery-shaped histories", () => { const call0 = assistantCalls(toolCallPart("a1", "fetch")); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("a1", fetchTool), suspendedEv([], [{ id: "a1", tool: fetchTool }]), @@ -808,7 +808,7 @@ describe("recovery-shaped histories", () => { it("accepts a live model failure closing the turn", () => { const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), callFailed(0), turnFailedEv("provider exploded"), ]); @@ -821,7 +821,7 @@ describe("recovery-shaped histories", () => { created({ config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 }, }), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -840,7 +840,7 @@ describe("invariants", () => { }); it("rejects a log not starting with turn_created", () => { - expectCorruption([requested(0, [{ kind: "input" }])], /first event must be turn_created/); + expectCorruption([requested(0, ["input"])], /first event must be turn_created/); }); it("rejects duplicate turn_created", () => { @@ -849,7 +849,7 @@ describe("invariants", () => { it("rejects mismatched turn ids", () => { expectCorruption( - [created(), { ...requested(0, [{ kind: "input" }]), turnId: "other" }], + [created(), { ...requested(0, ["input"]), turnId: "other" }], /does not match turn/, ); }); @@ -882,9 +882,9 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantText("a")), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), ], /out of order/, ); @@ -892,7 +892,7 @@ describe("invariants", () => { it("rejects concurrent unresolved model requests", () => { expectCorruption( - [created(), requested(0, [{ kind: "input" }]), requested(1, [])], + [created(), requested(0, ["input"]), requested(1, [])], /concurrent unresolved model call requests/, ); }); @@ -912,7 +912,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantText("a")), completed(0, assistantText("b")), ], @@ -924,7 +924,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), callFailed(0), completed(0, assistantText("a")), ], @@ -937,9 +937,9 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), - requested(1, [{ kind: "assistant", modelCallIndex: 0 }]), + requested(1, ["assistant:0"]), ], /while tool calls are unresolved/, ); @@ -952,11 +952,11 @@ describe("invariants", () => { created({ config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 }, }), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), - requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), + requested(1, ["assistant:0", "toolResult:tc1"]), ], /exceeds maxModelCalls/, ); @@ -966,7 +966,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed( 0, assistantCalls(toolCallPart("tc1", "echo"), toolCallPart("tc1", "echo")), @@ -981,11 +981,11 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), - requested(1, [{ kind: "assistant", modelCallIndex: 0 }, { kind: "toolResult", toolCallId: "tc1" }]), + requested(1, ["assistant:0", "toolResult:tc1"]), completed(1, assistantCalls(toolCallPart("tc1", "echo"))), ], /duplicate tool call id/, @@ -1000,16 +1000,16 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), invocation("tc2"), result("tc2", "echo"), requested(1, [ - { kind: "assistant", modelCallIndex: 0 }, - { kind: "toolResult", toolCallId: "tc2" }, - { kind: "toolResult", toolCallId: "tc1" }, + "assistant:0", + "toolResult:tc2", + "toolResult:tc1", ]), ], /references do not match/, @@ -1018,7 +1018,7 @@ describe("invariants", () => { it("rejects permission records targeting unknown tool calls", () => { expectCorruption( - [created(), requested(0, [{ kind: "input" }]), completed(0, assistantText("a")), permRequired("ghost", "echo")], + [created(), requested(0, ["input"]), completed(0, assistantText("a")), permRequired("ghost", "echo")], /unknown tool call/, ); }); @@ -1028,7 +1028,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permRequired("tc1", "echo"), @@ -1042,7 +1042,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permClassified("tc1", "allow"), ], @@ -1055,7 +1055,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permResolved("tc1", "allow"), ], @@ -1068,7 +1068,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "allow"), @@ -1083,7 +1083,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), invocation("tc1"), @@ -1097,7 +1097,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), permResolved("tc1", "deny"), @@ -1112,7 +1112,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), invocation("tc1"), @@ -1126,7 +1126,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1", echoTool, { execution: "async" }), ], @@ -1137,7 +1137,7 @@ describe("invariants", () => { it("rejects progress without invocation", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [{ kind: "input" }]), completed(0, call0), progress("tc1")], + [created(), requested(0, ["input"]), completed(0, call0), progress("tc1")], /progress without invocation/, ); }); @@ -1147,7 +1147,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -1162,7 +1162,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -1175,7 +1175,7 @@ describe("invariants", () => { it("rejects sync-sourced results without invocation", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [{ kind: "input" }]), completed(0, call0), result("tc1", "echo", "sync")], + [created(), requested(0, ["input"]), completed(0, call0), result("tc1", "echo", "sync")], /sync tool result without invocation/, ); }); @@ -1185,7 +1185,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo", "async"), @@ -1199,7 +1199,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantText("a")), stepEvent(0), ], @@ -1210,7 +1210,7 @@ describe("invariants", () => { it("rejects completion while tool calls remain unresolved", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnCompletedEv()], + [created(), requested(0, ["input"]), completed(0, call0), turnCompletedEv()], /completion while tool calls lack terminal results/, ); }); @@ -1220,7 +1220,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), result("tc1", "echo"), @@ -1237,14 +1237,14 @@ describe("invariants", () => { it("rejects terminal failure while tool calls remain unresolved", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); expectCorruption( - [created(), requested(0, [{ kind: "input" }]), completed(0, call0), turnFailedEv()], + [created(), requested(0, ["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, [{ kind: "input" }]), turnCancelledEv()], + [created(), requested(0, ["input"]), turnCancelledEv()], /cancellation while a model call is unsettled/, ); }); @@ -1252,7 +1252,7 @@ describe("invariants", () => { it("rejects any event after a terminal event", () => { const base = [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantText("a")), ]; for (const terminal of [turnCompletedEv(), turnFailedEv(), turnCancelledEv()]) { @@ -1267,7 +1267,7 @@ describe("invariants", () => { expectCorruption( [ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, assistantText("a")), turnCompletedEv(), turnFailedEv(), @@ -1282,14 +1282,14 @@ describe("derivations", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const pending = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), permRequired("tc1", "echo"), suspendedEv([{ id: "tc1", name: "echo" }], []), ]); expect(deriveTurnStatus(pending)).toBe("suspended"); - const idle = reduceTurn([created(), requested(0, [{ kind: "input" }]), completed(0, call0)]); + const idle = reduceTurn([created(), requested(0, ["input"]), completed(0, call0)]); expect(deriveTurnStatus(idle)).toBe("idle"); }); @@ -1300,16 +1300,16 @@ describe("derivations", () => { ); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), completed(0, call0), invocation("tc1"), invocation("tc2"), result("tc2", "echo", "sync", { n: 2 }), result("tc1", "echo", "sync", "plain text"), requested(1, [ - { kind: "assistant", modelCallIndex: 0 }, - { kind: "toolResult", toolCallId: "tc1" }, - { kind: "toolResult", toolCallId: "tc2" }, + "assistant:0", + "toolResult:tc1", + "toolResult:tc2", ]), completed(1, assistantText("done")), turnCompletedEv(), @@ -1326,7 +1326,7 @@ describe("derivations", () => { it("omits failed model calls from the transcript", () => { const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["input"]), callFailed(0, "interrupted"), requested(1, []), completed(1, assistantText("done")), @@ -1338,7 +1338,7 @@ describe("derivations", () => { it("transcript excludes the context prefix", () => { const state = reduceTurn([ created({ context: { previousTurnId: PREV_TURN_ID } }), - requested(0, [{ kind: "input" }], { + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID }, }), completed(0, assistantText("done")), @@ -1352,7 +1352,7 @@ describe("derivations", () => { const call0 = assistantCalls(toolCallPart("tc1", "echo")); const state = reduceTurn([ created(), - requested(0, [{ kind: "input" }]), + requested(0, ["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 3b71b0d3..6d28e035 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -130,15 +130,39 @@ export const TurnCreated = z.object({ // 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() }), -]); +// Compact string refs so raw JSONL reads naturally: +// "context" | "input" | "assistant:" | "toolResult:" +export const ModelRequestMessageRef = z + .string() + .regex(/^(context|input|assistant:\d+|toolResult:.+)$/); + +export type ParsedRequestRef = + | { kind: "context" } + | { kind: "input" } + | { kind: "assistant"; modelCallIndex: number } + | { kind: "toolResult"; toolCallId: string }; + +export const assistantRef = (modelCallIndex: number): string => + `assistant:${modelCallIndex}`; +export const toolResultRef = (toolCallId: string): string => + `toolResult:${toolCallId}`; + +export function parseRequestRef(ref: string): ParsedRequestRef { + if (ref === "context" || ref === "input") { + return { kind: ref }; + } + if (ref.startsWith("assistant:")) { + const modelCallIndex = Number(ref.slice("assistant:".length)); + if (!Number.isInteger(modelCallIndex) || modelCallIndex < 0) { + throw new Error(`malformed request ref: ${ref}`); + } + return { kind: "assistant", modelCallIndex }; + } + if (ref.startsWith("toolResult:")) { + return { kind: "toolResult", toolCallId: ref.slice("toolResult:".length) }; + } + throw new Error(`malformed request ref: ${ref}`); +} export const ModelRequest = z.object({ contextRef: TurnContextRef.optional(), @@ -505,21 +529,18 @@ function applyModelCallRequested( } const context = state.definition.context; - let expectedRefs: Array>; + let expectedRefs: string[]; 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" }]; + expectedRefs = ["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" }]; + expectedRefs = context.length > 0 ? ["context", "input"] : ["input"]; } } else { if (event.request.contextRef !== undefined) { @@ -529,11 +550,10 @@ function applyModelCallRequested( expectedRefs = previous.response !== undefined ? [ - { kind: "assistant", modelCallIndex: previous.index }, - ...batchToolCalls(state, previous.index).map((tc) => ({ - kind: "toolResult" as const, - toolCallId: tc.toolCallId, - })), + assistantRef(previous.index), + ...batchToolCalls(state, previous.index).map((tc) => + toolResultRef(tc.toolCallId), + ), ] : []; // re-issue after an interrupted call adds nothing new } @@ -988,7 +1008,8 @@ export function requestMessagesFor( if (!call) { throw new Error(`no model call at index ${modelCallIndex}`); } - return call.request.messages.flatMap((ref) => { + return call.request.messages.flatMap((raw) => { + const ref = parseRequestRef(raw); switch (ref.kind) { case "context": { const context = state.definition.context;