rowboat/apps/x/packages/shared/src/turns.test.ts

1422 lines
45 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
type JsonValue,
ModelCallCompleted,
ModelCallFailed,
ModelCallRequested,
ModelStepEvent,
MODEL_CALL_LIMIT_ERROR_CODE,
ToolDescriptor,
ToolInvocationRequested,
ToolPermissionClassificationFailed,
ToolPermissionClassified,
ToolPermissionRequired,
ToolPermissionResolved,
ToolProgress,
ToolResult,
TurnCancelled,
TurnCompleted,
TurnCorruptionError,
TurnCreated,
TurnEvent,
TurnFailed,
TurnSuspended,
deriveTurnStatus,
outstandingAsyncTools,
outstandingPermissions,
reduceTurn,
turnTranscript,
} from "./turns.js";
type TEvent = z.infer<typeof TurnEvent>;
const TURN_ID = "2026-07-02T10-00-00Z-0000001-000";
const PREV_TURN_ID = "2026-07-02T09-00-00Z-0000001-000";
const TS = "2026-07-02T10:00:00Z";
const echoTool: z.infer<typeof ToolDescriptor> = {
toolId: "tool.echo",
name: "echo",
description: "Echo tool",
inputSchema: {},
execution: "sync",
requiresHuman: false,
};
const fetchTool: z.infer<typeof ToolDescriptor> = {
toolId: "tool.fetch",
name: "fetch",
description: "Async fetch tool",
inputSchema: {},
execution: "async",
requiresHuman: false,
};
function user(text: string) {
return { role: "user" as const, content: text };
}
function assistantText(text: string) {
return { role: "assistant" as const, content: text };
}
function toolCallPart(id: string, name: string, args: JsonValue = {}) {
return {
type: "tool-call" as const,
toolCallId: id,
toolName: name,
arguments: args,
};
}
function assistantCalls(...parts: Array<ReturnType<typeof toolCallPart>>) {
return { role: "assistant" as const, content: parts };
}
function toolMsg(id: string, name: string, content = "ok") {
return { role: "tool" as const, content, toolCallId: id, toolName: name };
}
function created(
overrides: Partial<z.infer<typeof TurnCreated>> = {},
): z.infer<typeof TurnCreated> {
return {
type: "turn_created",
schemaVersion: 1,
turnId: TURN_ID,
ts: TS,
sessionId: null,
agent: {
requested: { agentId: "copilot" },
resolved: {
agentId: "copilot",
systemPrompt: "SYS",
model: { provider: "openai", model: "gpt-test" },
tools: [echoTool, fetchTool],
},
},
context: [],
input: user("hello"),
config: {
autoPermission: false,
humanAvailable: true,
maxModelCalls: 20,
},
...overrides,
};
}
function requested(
index: number,
messages: z.infer<typeof ModelCallRequested>["request"]["messages"],
requestOverrides: Partial<z.infer<typeof ModelCallRequested>["request"]> = {},
): z.infer<typeof ModelCallRequested> {
return {
type: "model_call_requested",
turnId: TURN_ID,
ts: TS,
modelCallIndex: index,
request: {
messages,
parameters: {},
...requestOverrides,
},
};
}
function completed(
index: number,
message: z.infer<typeof ModelCallCompleted>["message"],
usage: z.infer<typeof ModelCallCompleted>["usage"] = {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
},
): z.infer<typeof ModelCallCompleted> {
return {
type: "model_call_completed",
turnId: TURN_ID,
ts: TS,
modelCallIndex: index,
message,
finishReason: "stop",
usage,
};
}
function callFailed(
index: number,
error = "provider exploded",
): z.infer<typeof ModelCallFailed> {
return {
type: "model_call_failed",
turnId: TURN_ID,
ts: TS,
modelCallIndex: index,
error,
};
}
function stepEvent(
index: number,
event: z.infer<typeof ModelStepEvent>["event"] = {
type: "text_end",
text: "chunk",
},
): z.infer<typeof ModelStepEvent> {
return {
type: "model_step_event",
turnId: TURN_ID,
ts: TS,
modelCallIndex: index,
event,
};
}
function permRequired(
id: string,
name: string,
checkerError?: string,
): z.infer<typeof ToolPermissionRequired> {
return {
type: "tool_permission_required",
turnId: TURN_ID,
ts: TS,
toolCallId: id,
toolName: name,
request: { tool: name },
...(checkerError === undefined ? {} : { checkerError }),
};
}
function permClassified(
id: string,
decision: z.infer<typeof ToolPermissionClassified>["decision"],
): z.infer<typeof ToolPermissionClassified> {
return {
type: "tool_permission_classified",
turnId: TURN_ID,
ts: TS,
toolCallId: id,
decision,
reason: "because",
};
}
function permClassificationFailed(
ids: string[],
): z.infer<typeof ToolPermissionClassificationFailed> {
return {
type: "tool_permission_classification_failed",
turnId: TURN_ID,
ts: TS,
toolCallIds: ids,
error: "classifier timed out",
};
}
function permResolved(
id: string,
decision: z.infer<typeof ToolPermissionResolved>["decision"],
source: z.infer<typeof ToolPermissionResolved>["source"] = "human",
): z.infer<typeof ToolPermissionResolved> {
return {
type: "tool_permission_resolved",
turnId: TURN_ID,
ts: TS,
toolCallId: id,
decision,
source,
};
}
function invocation(
id: string,
tool: z.infer<typeof ToolDescriptor> = echoTool,
overrides: Partial<z.infer<typeof ToolInvocationRequested>> = {},
): z.infer<typeof ToolInvocationRequested> {
return {
type: "tool_invocation_requested",
turnId: TURN_ID,
ts: TS,
toolCallId: id,
toolId: tool.toolId,
toolName: tool.name,
execution: tool.execution,
input: {},
...overrides,
};
}
function progress(
id: string,
source: z.infer<typeof ToolProgress>["source"] = "sync",
): z.infer<typeof ToolProgress> {
return {
type: "tool_progress",
turnId: TURN_ID,
ts: TS,
toolCallId: id,
source,
progress: { pct: 50 },
};
}
function result(
id: string,
name: string,
source: z.infer<typeof ToolResult>["source"] = "sync",
output: JsonValue = "ok",
isError = false,
): z.infer<typeof ToolResult> {
return {
type: "tool_result",
turnId: TURN_ID,
ts: TS,
toolCallId: id,
toolName: name,
source,
result: { output, isError },
};
}
function suspendedEv(
perms: Array<{ id: string; name: string }>,
asyncs: Array<{ id: string; tool: z.infer<typeof ToolDescriptor> }>,
): z.infer<typeof TurnSuspended> {
return {
type: "turn_suspended",
turnId: TURN_ID,
ts: TS,
pendingPermissions: perms.map((p) => ({
toolCallId: p.id,
toolName: p.name,
request: { tool: p.name },
})),
pendingAsyncTools: asyncs.map((a) => ({
toolCallId: a.id,
toolId: a.tool.toolId,
toolName: a.tool.name,
input: {},
})),
usage: {},
};
}
function turnCompletedEv(
output: z.infer<typeof TurnCompleted>["output"] = assistantText("done"),
): z.infer<typeof TurnCompleted> {
return {
type: "turn_completed",
turnId: TURN_ID,
ts: TS,
output,
finishReason: "stop",
usage: {},
};
}
function turnFailedEv(
error = "it broke",
code?: string,
): z.infer<typeof TurnFailed> {
return {
type: "turn_failed",
turnId: TURN_ID,
ts: TS,
error,
...(code === undefined ? {} : { code }),
usage: {},
};
}
function turnCancelledEv(reason?: string): z.infer<typeof TurnCancelled> {
return {
type: "turn_cancelled",
turnId: TURN_ID,
ts: TS,
...(reason === undefined ? {} : { reason }),
usage: {},
};
}
// A complete happy-path sequence with one sync tool round trip.
function syncToolSequence(): TEvent[] {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
return [
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "allow"),
invocation("tc1"),
result("tc1", "echo"),
requested(1, ["assistant:0", "toolResult:tc1"]),
completed(1, assistantText("done")),
turnCompletedEv(),
];
}
function expectCorruption(events: TEvent[], match: string | RegExp): void {
expect(() => reduceTurn(events)).toThrowError(TurnCorruptionError);
expect(() => reduceTurn(events)).toThrowError(match);
}
describe("schemas", () => {
it("every builder output round-trips through the TurnEvent schema", () => {
for (const event of syncToolSequence()) {
expect(TurnEvent.parse(event)).toEqual(event);
}
});
it("rejects system-role messages in inline context", () => {
const bad = created({
context: [
{ role: "system", content: "sneaky" },
] as unknown as z.infer<typeof TurnCreated>["context"],
});
expect(() => TurnCreated.parse(bad)).toThrowError();
});
});
describe("plain completion", () => {
it("reduces a created-only log to an idle empty state", () => {
const state = reduceTurn([created()]);
expect(state.definition.turnId).toBe(TURN_ID);
expect(state.modelCalls).toEqual([]);
expect(state.toolCalls).toEqual([]);
expect(state.terminal).toBeUndefined();
expect(deriveTurnStatus(state)).toBe("idle");
});
it("reduces a plain model response to a completed turn", () => {
const state = reduceTurn([
created(),
requested(0, ["input"]),
stepEvent(0),
completed(0, assistantText("done")),
turnCompletedEv(),
]);
expect(state.modelCalls).toHaveLength(1);
expect(state.modelCalls[0].response).toEqual(assistantText("done"));
expect(state.modelCalls[0].finishReason).toBe("stop");
expect(state.modelCalls[0].stepEvents).toHaveLength(1);
expect(state.terminal?.type).toBe("turn_completed");
expect(deriveTurnStatus(state)).toBe("completed");
});
it("aggregates usage across model calls", () => {
const state = reduceTurn(syncToolSequence());
expect(state.usage).toEqual({
inputTokens: 20,
outputTokens: 10,
totalTokens: 30,
});
});
it("leaves usage fields undefined when never reported", () => {
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, assistantText("a"), { inputTokens: 5 }),
]);
expect(state.usage).toEqual({ inputTokens: 5 });
expect(state.usage.cachedInputTokens).toBeUndefined();
});
});
describe("tool execution", () => {
it("extracts tool calls with order, batch index, and descriptor identity", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo", { text: "a" }),
toolCallPart("a1", "fetch", { url: "u" }),
);
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
]);
expect(state.toolCalls).toHaveLength(2);
expect(state.toolCalls[0]).toMatchObject({
toolCallId: "tc1",
toolName: "echo",
toolId: "tool.echo",
execution: "sync",
modelCallIndex: 0,
order: 0,
input: { text: "a" },
});
expect(state.toolCalls[1]).toMatchObject({
toolCallId: "a1",
execution: "async",
order: 1,
});
});
it("leaves identity undefined for tools missing from the agent snapshot", () => {
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, assistantCalls(toolCallPart("x1", "unknown-tool"))),
result("x1", "unknown-tool", "runtime", "No such tool", true),
]);
expect(state.toolCalls[0].toolId).toBeUndefined();
expect(state.toolCalls[0].execution).toBeUndefined();
expect(state.toolCalls[0].result?.result.isError).toBe(true);
});
it("reduces a full sync tool round trip", () => {
const state = reduceTurn(syncToolSequence());
const tc = state.toolCalls[0];
expect(tc.permission?.required).toBeDefined();
expect(tc.permission?.resolved?.decision).toBe("allow");
expect(tc.invocation).toBeDefined();
expect(tc.result?.source).toBe("sync");
expect(state.terminal?.type).toBe("turn_completed");
});
it("records classifier provenance separately from the effective decision", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created({ config: { autoPermission: true, humanAvailable: true, maxModelCalls: 20 } }),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permClassified("tc1", "allow"),
permResolved("tc1", "allow", "classifier"),
invocation("tc1"),
result("tc1", "echo"),
]);
const permission = state.toolCalls[0].permission;
expect(permission?.classification?.decision).toBe("allow");
expect(permission?.resolved?.source).toBe("classifier");
});
it("accepts classification failure as audit and continues to human resolution", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permClassificationFailed(["tc1"]),
permResolved("tc1", "deny", "human"),
result("tc1", "echo", "runtime", "Permission denied", true),
]);
feat(x/core): turn runtime with event-sourced execution loop (stage 2) The TurnRuntime per turn-runtime-design.md: constructor-injected, container-ignorant, no per-turn state — every advanceTurn reconstructs from the JSONL log, so normal execution and crash recovery share one path. - runtime.ts: createTurn/advanceTurn/getTurn; durable-barrier appends before side effects; sequential sync tools with progress; async tool exposure + durable suspension; permission pipeline (checker → optional classifier batch → human/deny fallback, fail-closed on checker errors); cancellation with synthetic results; recovery that re-issues interrupted model calls and continues after interrupted sync tools; model-call-limit exhaustion with machine-readable code. - fs-repo.ts: date-partitioned append-only JSONL, strict line validation, path-traversal rejection, per-turn in-process locking. - stream.ts: hot execution stream — buffers before the consumer attaches, outcome independent of consumption, close-drops-events. - context-resolver.ts: chain-walking materialization of context references with cycle detection. - Seam interfaces for stage-4 bridges: model/tool registries, permission checker/classifier, agent resolver, lifecycle bus, clock. - shared reducer: track classification failures durably so failed classifications are never re-run. 67 new tests: all nine §26 end-to-end scenarios (mocked deps, recovery boundaries seeded directly) plus repo/stream/resolver suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:32:27 +05:30
expect(state.toolCalls[0].permission?.classificationFailed).toBe(true);
expect(state.toolCalls[0].result?.result.isError).toBe(true);
});
it("accumulates tool progress", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
progress("tc1"),
progress("tc1"),
result("tc1", "echo"),
]);
expect(state.toolCalls[0].progress).toHaveLength(2);
});
it("accepts denial results without invocation (runtime source)", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "deny"),
result("tc1", "echo", "runtime", "Permission denied", true),
]);
expect(state.toolCalls[0].invocation).toBeUndefined();
expect(state.toolCalls[0].result?.source).toBe("runtime");
});
});
describe("context references", () => {
it("accepts a referenced context with matching contextRef on requests", () => {
const state = reduceTurn([
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, ["input"], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
completed(0, assistantText("done")),
turnCompletedEv(),
]);
expect(deriveTurnStatus(state)).toBe("completed");
});
it("rejects a request missing the contextRef when context is a reference", () => {
expectCorruption(
[
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, ["input"]),
],
/contextRef inconsistent/,
);
});
it("rejects a request whose contextRef targets the wrong turn", () => {
expectCorruption(
[
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, ["input"], {
contextRef: { previousTurnId: "some-other-turn" },
}),
],
/contextRef inconsistent/,
);
});
it("rejects a contextRef when the turn context is inline", () => {
expectCorruption(
[
created(),
requested(0, ["input"], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
],
/contextRef but turn context is inline/,
);
});
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
it("requires a {context} ref before {input} when inline context is nonempty", () => {
const context = [
user("earlier"),
assistantCalls(toolCallPart("old1", "echo")),
toolMsg("old1", "echo"),
];
const state = reduceTurn([
created({ context }),
requested(0, ["context", "input"]),
completed(0, assistantText("done")),
turnCompletedEv(),
]);
expect(deriveTurnStatus(state)).toBe("completed");
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
expectCorruption(
[created({ context }), requested(0, ["input"])],
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
/references do not match/,
);
});
it("rejects a contextRef on a non-initial model call", () => {
expectCorruption(
[
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, ["input"], {
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
contextRef: { previousTurnId: PREV_TURN_ID },
}),
completed(0, assistantCalls(toolCallPart("tc1", "echo"))),
invocation("tc1"),
result("tc1", "echo"),
requested(
1,
[
"assistant:0",
"toolResult:tc1",
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
],
{ contextRef: { previousTurnId: PREV_TURN_ID } },
),
],
/contextRef on a non-initial model call/,
);
});
});
2026-07-02 13:17:30 +05:30
describe("inherited agent snapshots", () => {
const inherited = {
agentId: "copilot",
model: { provider: "openai", model: "gpt-test" },
inheritedFrom: PREV_TURN_ID,
};
it("accepts inheritance referencing the context predecessor; identity arrives via invocation", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created({
context: { previousTurnId: PREV_TURN_ID },
agent: { requested: { agentId: "copilot" }, resolved: inherited },
}),
requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID } }),
completed(0, call0),
]);
// No descriptor lookup without the concrete snapshot…
expect(state.toolCalls[0].toolId).toBeUndefined();
expect(state.toolCalls[0].execution).toBeUndefined();
// …until tool_invocation_requested supplies identity.
const withInvocation = reduceTurn([
created({
context: { previousTurnId: PREV_TURN_ID },
agent: { requested: { agentId: "copilot" }, resolved: inherited },
}),
requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID } }),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
]);
expect(withInvocation.toolCalls[0].toolId).toBe("tool.echo");
expect(withInvocation.toolCalls[0].execution).toBe("sync");
});
it("rejects an inherited snapshot on an inline-context turn", () => {
expectCorruption(
[
created({
context: [],
agent: { requested: { agentId: "copilot" }, resolved: inherited },
}),
],
/inherited agent snapshot must reference the turn's context predecessor/,
);
});
it("rejects an inherited snapshot pointing at a different turn than the context", () => {
expectCorruption(
[
created({
context: { previousTurnId: "some-other-turn" },
agent: { requested: { agentId: "copilot" }, resolved: inherited },
}),
],
/inherited agent snapshot must reference the turn's context predecessor/,
);
});
});
describe("suspension", () => {
it("accepts a snapshot matching pending permissions and async tools", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo"),
toolCallPart("a1", "fetch"),
);
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("a1", fetchTool),
suspendedEv([{ id: "tc1", name: "echo" }], [{ id: "a1", tool: fetchTool }]),
]);
expect(state.suspension?.pendingPermissions).toHaveLength(1);
expect(state.suspension?.pendingAsyncTools).toHaveLength(1);
expect(deriveTurnStatus(state)).toBe("suspended");
});
it("replaces the snapshot as inputs arrive, one at a time", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo"),
toolCallPart("a1", "fetch"),
);
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("a1", fetchTool),
suspendedEv([{ id: "tc1", name: "echo" }], [{ id: "a1", tool: fetchTool }]),
result("a1", "fetch", "async", { data: 1 }),
suspendedEv([{ id: "tc1", name: "echo" }], []),
]);
expect(state.suspension?.pendingAsyncTools).toHaveLength(0);
expect(outstandingPermissions(state)).toHaveLength(1);
expect(outstandingAsyncTools(state)).toHaveLength(0);
});
it("supports async results arriving in any order before the next model call", () => {
const call0 = assistantCalls(
toolCallPart("a1", "fetch"),
toolCallPart("a2", "fetch"),
);
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("a1", fetchTool),
invocation("a2", fetchTool),
suspendedEv([], [{ id: "a1", tool: fetchTool }, { id: "a2", tool: fetchTool }]),
result("a2", "fetch", "async", "second"),
suspendedEv([], [{ id: "a1", tool: fetchTool }]),
result("a1", "fetch", "async", "first"),
requested(1, [
"assistant:0",
"toolResult:a1",
"toolResult:a2",
]),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
expect(deriveTurnStatus(state)).toBe("completed");
});
it("rejects a snapshot claiming already-resolved work", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "allow"),
invocation("tc1"),
result("tc1", "echo"),
suspendedEv([{ id: "tc1", name: "echo" }], []),
],
/suspension without pending external work/,
);
});
it("rejects a snapshot omitting pending work", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo"),
toolCallPart("a1", "fetch"),
);
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("a1", fetchTool),
suspendedEv([{ id: "tc1", name: "echo" }], []),
],
/suspension snapshot inconsistent/,
);
});
it("rejects suspension while a model call is unsettled", () => {
expectCorruption(
[
created(),
requested(0, ["input"]),
suspendedEv([{ id: "tc1", name: "echo" }], []),
],
/suspension while a model call is unsettled/,
);
});
});
describe("recovery-shaped histories", () => {
it("accepts an interrupted model call closed and re-issued (§23 fix)", () => {
const state = reduceTurn([
created(),
requested(0, ["input"]),
callFailed(0, "interrupted by process restart"),
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
requested(1, []),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
expect(state.modelCalls[0].error).toMatch(/interrupted/);
expect(state.modelCalls[1].response).toEqual(assistantText("done"));
expect(deriveTurnStatus(state)).toBe("completed");
});
it("re-issued model calls count against maxModelCalls", () => {
expectCorruption(
[
created({
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
}),
requested(0, ["input"]),
callFailed(0, "interrupted"),
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
requested(1, []),
],
/exceeds maxModelCalls/,
);
});
it("accepts an interrupted sync tool closed with an indeterminate runtime result, turn continuing (§23 fix)", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result(
"tc1",
"echo",
"runtime",
"Tool execution was interrupted; its outcome is unknown and it was not retried.",
true,
),
requested(1, ["assistant:0", "toolResult:tc1"]),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
expect(state.toolCalls[0].result?.source).toBe("runtime");
expect(state.terminal?.type).toBe("turn_completed");
});
it("accepts cancellation with synthetic runtime results for unresolved calls", () => {
const call0 = assistantCalls(toolCallPart("a1", "fetch"));
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("a1", fetchTool),
suspendedEv([], [{ id: "a1", tool: fetchTool }]),
result("a1", "fetch", "runtime", "Cancelled", true),
turnCancelledEv("user stop"),
]);
expect(state.terminal?.type).toBe("turn_cancelled");
expect(deriveTurnStatus(state)).toBe("cancelled");
});
it("accepts a live model failure closing the turn", () => {
const state = reduceTurn([
created(),
requested(0, ["input"]),
callFailed(0),
turnFailedEv("provider exploded"),
]);
expect(deriveTurnStatus(state)).toBe("failed");
});
it("accepts model-call-limit exhaustion with a machine-readable code", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created({
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
}),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
turnFailedEv("model call limit reached", MODEL_CALL_LIMIT_ERROR_CODE),
]);
expect(state.terminal?.type).toBe("turn_failed");
expect(
state.terminal?.type === "turn_failed" ? state.terminal.code : undefined,
).toBe(MODEL_CALL_LIMIT_ERROR_CODE);
});
});
describe("invariants", () => {
it("rejects an empty log", () => {
expectCorruption([], /empty/);
});
it("rejects a log not starting with turn_created", () => {
expectCorruption([requested(0, ["input"])], /first event must be turn_created/);
});
it("rejects duplicate turn_created", () => {
expectCorruption([created(), created()], /duplicate turn_created/);
});
it("rejects mismatched turn ids", () => {
expectCorruption(
[created(), { ...requested(0, ["input"]), turnId: "other" }],
/does not match turn/,
);
});
it("rejects unsupported schema versions", () => {
const bad = {
...created(),
schemaVersion: 2,
} as unknown as TEvent;
expectCorruption([bad], /unsupported turn schema version/);
});
it("rejects unknown event types", () => {
const bad = {
type: "wat",
turnId: TURN_ID,
ts: TS,
} as unknown as TEvent;
expectCorruption([created(), bad], /unknown turn event type/);
});
it("rejects out-of-order model call indices", () => {
expectCorruption(
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
[created(), requested(1, [])],
/out of order/,
);
});
it("rejects a reused model call index", () => {
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, assistantText("a")),
requested(0, ["input"]),
],
/out of order/,
);
});
it("rejects concurrent unresolved model requests", () => {
expectCorruption(
[created(), requested(0, ["input"]), requested(1, [])],
/concurrent unresolved model call requests/,
);
});
it("rejects completion without a matching request", () => {
expectCorruption(
[created(), completed(0, assistantText("a"))],
/without matching request/,
);
});
it("rejects failure without a matching request", () => {
expectCorruption([created(), callFailed(0)], /without matching request/);
});
it("rejects duplicate model call completion", () => {
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, assistantText("a")),
completed(0, assistantText("b")),
],
/duplicate settlement/,
);
});
it("rejects completion after failure of the same call", () => {
expectCorruption(
[
created(),
requested(0, ["input"]),
callFailed(0),
completed(0, assistantText("a")),
],
/duplicate settlement/,
);
});
it("rejects the next model call while tool calls are unresolved", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
requested(1, ["assistant:0"]),
],
/while tool calls are unresolved/,
);
});
it("rejects a model call past the budget", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created({
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 1 },
}),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
requested(1, ["assistant:0", "toolResult:tc1"]),
],
/exceeds maxModelCalls/,
);
});
it("rejects duplicate tool call ids within one response", () => {
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(
0,
assistantCalls(toolCallPart("tc1", "echo"), toolCallPart("tc1", "echo")),
),
],
/duplicate tool call id/,
);
});
it("rejects duplicate tool call ids across responses", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
requested(1, ["assistant:0", "toolResult:tc1"]),
completed(1, assistantCalls(toolCallPart("tc1", "echo"))),
],
/duplicate tool call id/,
);
});
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
it("rejects request references whose tool-result order differs from the batch", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo"),
toolCallPart("tc2", "echo"),
);
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
invocation("tc2"),
result("tc2", "echo"),
requested(1, [
"assistant:0",
"toolResult:tc2",
"toolResult:tc1",
]),
],
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
/references do not match/,
);
});
it("rejects permission records targeting unknown tool calls", () => {
expectCorruption(
[created(), requested(0, ["input"]), completed(0, assistantText("a")), permRequired("ghost", "echo")],
/unknown tool call/,
);
});
it("rejects duplicate permission requirements", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permRequired("tc1", "echo"),
],
/duplicate permission requirement/,
);
});
it("rejects classification without a requirement", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permClassified("tc1", "allow"),
],
/classification without permission requirement/,
);
});
it("rejects resolution without a requirement", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permResolved("tc1", "allow"),
],
/resolution without requirement/,
);
});
it("rejects conflicting effective permission decisions", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "allow"),
permResolved("tc1", "deny"),
],
/conflicting permission decisions/,
);
});
it("rejects invocation while permission is pending", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
invocation("tc1"),
],
/invocation without permission allowance/,
);
});
it("rejects invocation of a denied tool call", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
permRequired("tc1", "echo"),
permResolved("tc1", "deny"),
invocation("tc1"),
],
/invocation without permission allowance/,
);
});
it("rejects duplicate invocations", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
invocation("tc1"),
],
/duplicate tool invocation/,
);
});
it("rejects invocations whose execution mode contradicts the snapshot", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1", echoTool, { execution: "async" }),
],
/execution mismatch/,
);
});
it("rejects progress without invocation", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, ["input"]), completed(0, call0), progress("tc1")],
/progress without invocation/,
);
});
it("rejects progress after a terminal tool result", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
progress("tc1"),
],
/progress after terminal result/,
);
});
it("rejects duplicate tool results", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
result("tc1", "echo"),
],
/duplicate tool result/,
);
});
it("rejects sync-sourced results without invocation", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, ["input"]), completed(0, call0), result("tc1", "echo", "sync")],
/sync tool result without invocation/,
);
});
it("rejects async results for sync tools", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo", "async"),
],
/result source mismatch/,
);
});
it("rejects step events without a matching open call", () => {
expectCorruption([created(), stepEvent(0)], /without matching model call request/);
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, assistantText("a")),
stepEvent(0),
],
/after model call 0 settled/,
);
});
it("rejects completion while tool calls remain unresolved", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[created(), requested(0, ["input"]), completed(0, call0), turnCompletedEv()],
/completion while tool calls lack terminal results/,
);
});
it("rejects completion when the final response has tool calls", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
result("tc1", "echo"),
turnCompletedEv(),
],
/final response has tool calls/,
);
});
it("rejects completion without any completed model response", () => {
expectCorruption([created(), turnCompletedEv()], /without a completed model response/);
});
it("rejects terminal failure while tool calls remain unresolved", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
expectCorruption(
[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, ["input"]), turnCancelledEv()],
/cancellation while a model call is unsettled/,
);
});
it("rejects any event after a terminal event", () => {
const base = [
created(),
requested(0, ["input"]),
completed(0, assistantText("a")),
];
for (const terminal of [turnCompletedEv(), turnFailedEv(), turnCancelledEv()]) {
expectCorruption(
[...base, terminal, stepEvent(0)],
/event after terminal turn event/,
);
}
});
it("rejects multiple terminal events", () => {
expectCorruption(
[
created(),
requested(0, ["input"]),
completed(0, assistantText("a")),
turnCompletedEv(),
turnFailedEv(),
],
/event after terminal turn event/,
);
});
});
describe("derivations", () => {
it("derives suspended for pending permissions and idle otherwise", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const pending = reduceTurn([
created(),
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, ["input"]), completed(0, call0)]);
expect(deriveTurnStatus(idle)).toBe("idle");
});
it("builds the turn transcript in source order with serialized results", () => {
const call0 = assistantCalls(
toolCallPart("tc1", "echo"),
toolCallPart("tc2", "echo"),
);
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
invocation("tc1"),
invocation("tc2"),
result("tc2", "echo", "sync", { n: 2 }),
result("tc1", "echo", "sync", "plain text"),
requested(1, [
"assistant:0",
"toolResult:tc1",
"toolResult:tc2",
]),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
expect(turnTranscript(state)).toEqual([
user("hello"),
call0,
{ role: "tool", content: "plain text", toolCallId: "tc1", toolName: "echo" },
{ role: "tool", content: '{"n":2}', toolCallId: "tc2", toolName: "echo" },
assistantText("done"),
]);
});
it("omits failed model calls from the transcript", () => {
const state = reduceTurn([
created(),
requested(0, ["input"]),
callFailed(0, "interrupted"),
feat(x): reference-based model requests + wire-form composer model_call_requested carried three duplications that dominated turn-file size (measured on a real 647KB two-step turn): the system prompt (28KB) and the 38-tool snapshot (40KB) repeated per call despite being byte- identical to turn_created.agent.resolved, and messages re-inlining the whole current-turn transcript (230KB re-stating a 207KB tool result). ModelRequest is now a list of references into the turn's own events — call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that batch's toolResults in source order]; re-issue after an interruption: []. Every referenced byte exists exactly once in the file; the measured turn drops to ~285KB and each further model call costs ~200 bytes. The reducer's ordering invariant got stricter: reference lists are matched exactly against the transcript. Debuggability is now byte-for-byte at the wire level: ResolvedModel gains encodeMessages (the structural->wire conversion — user-message context weaving, attachment rendering, tool-result enveloping, i.e. convertFromMessages), and composeModelRequest rebuilds the exact provider payload (resolved system prompt + wrapped tools + materialized prefix + resolved refs, encoded). The loop transmits exactly the composer's output, so the durable file plus the composer reproduce what the model received — pinned by a property test asserting composed == sent for every call, and a 2KB size guard on request events. A bridge test demonstrates the woven wire form (no raw userMessageContext). Breaking for existing dev turn files (same schemaVersion, pre-release): wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:46:23 +05:30
requested(1, []),
completed(1, assistantText("done")),
turnCompletedEv(),
]);
expect(turnTranscript(state)).toEqual([user("hello"), assistantText("done")]);
});
it("transcript excludes the context prefix", () => {
const state = reduceTurn([
created({ context: { previousTurnId: PREV_TURN_ID } }),
requested(0, ["input"], {
contextRef: { previousTurnId: PREV_TURN_ID },
}),
completed(0, assistantText("done")),
turnCompletedEv(),
]);
expect(turnTranscript(state)[0]).toEqual(user("hello"));
expect(turnTranscript(state)).toHaveLength(2);
});
it("transcript throws on unresolved tool calls", () => {
const call0 = assistantCalls(toolCallPart("tc1", "echo"));
const state = reduceTurn([
created(),
requested(0, ["input"]),
completed(0, call0),
]);
expect(() => turnTranscript(state)).toThrowError(/unresolved/);
});
});