feat(x): compact string request refs + turn inspector CLI

Two debuggability follow-ups from reviewing real turn files:

- Request refs are now compact strings — 'context' | 'input' |
  'assistant:<index>' | 'toolResult:<toolCallId>' — so a raw JSONL line
  reads naturally: {"messages": ["assistant:0", "toolResult:toolu_…"]}.
  Same exact-match reducer invariants, via parseRequestRef.

- npm run inspect-turn -- <turnId|path> [callIndex] [--full]: prints,
  per model call, the EXACT provider payload (resolved system prompt,
  tool list, wire-form messages with user-message context woven in, and
  the response/failure) — rebuilt by the same composer the loop sends
  through. This is the missing viewer for the derived-not-duplicated
  request design: the file stores facts once; the inspector shows what
  the model actually received.

Breaking for turn files written since the previous commit (dev only):
wipe ~/.rowboat/storage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 13:00:33 +05:30
parent 0ab09d4d47
commit 97169be93e
10 changed files with 261 additions and 135 deletions

View file

@ -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:<index>" → that model_call_completed's message
// "toolResult:<toolCallId>" → 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:<id>" 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.

View file

@ -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",

View file

@ -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":

View file

@ -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: {},
},
},

View file

@ -41,7 +41,7 @@ function requested(turnId = TURN_ID): z.infer<typeof TurnEvent> {
ts: "2026-07-02T10:00:01Z",
modelCallIndex: 0,
request: {
messages: [{ kind: "input" }],
messages: ["input"],
parameters: {},
},
};

View file

@ -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 -- <turnId | path/to/turn.jsonl> [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 <turnId | path/to/turn.jsonl> [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<void> {
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<z.infer<typeof TurnEvent>>;
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<typeof TurnEvent>);
} 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<typeof convertFromMessages>[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);
});

View file

@ -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<typeof ModelRequestMessageRef>[] = [{ kind: "input" }],
refs: string[] = ["input"],
): z.infer<typeof TurnEvent> {
return {
type: "model_call_requested",

View file

@ -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<z.infer<typeof ModelRequestMessageRef>>;
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
}

View file

@ -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/);

View file

@ -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:<modelCallIndex>" | "toolResult:<toolCallId>"
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<z.infer<typeof ModelRequestMessageRef>>;
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;