From d821c419fe1e61f5aa35244b771966433b1c4aef Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:46:07 +0530 Subject: [PATCH] feat(x/shared): turn + session event schemas and pure reducers (stage 1) Durable contracts for the new turn/session runtime, shared by core and renderer: - turns.ts: zod schemas for all 16 durable turn events, TurnContext (previousTurnId ref | inline messages), ModelRequest with contextRef and current-turn-only messages, ephemeral delta types, reduceTurn enforcing every spec invariant, and pure derivations (deriveTurnStatus, turnTranscript, outstanding work). - sessions.ts: session_created/turn_appended/title_changed schemas, reduceSession, and the SessionIndexEntry projection. - vitest wiring for packages/shared (config, build-excluded tests); 92 tests covering happy paths, recovery-shaped histories, and one named test per corruption invariant. Co-Authored-By: Claude Fable 5 --- apps/x/packages/shared/package.json | 9 +- apps/x/packages/shared/src/sessions.test.ts | 201 +++ apps/x/packages/shared/src/sessions.ts | 186 +++ apps/x/packages/shared/src/turns.test.ts | 1337 +++++++++++++++++++ apps/x/packages/shared/src/turns.ts | 958 +++++++++++++ apps/x/packages/shared/tsconfig.build.json | 7 + apps/x/packages/shared/vitest.config.ts | 11 + apps/x/pnpm-lock.yaml | 4 + 8 files changed, 2711 insertions(+), 2 deletions(-) create mode 100644 apps/x/packages/shared/src/sessions.test.ts create mode 100644 apps/x/packages/shared/src/sessions.ts create mode 100644 apps/x/packages/shared/src/turns.test.ts create mode 100644 apps/x/packages/shared/src/turns.ts create mode 100644 apps/x/packages/shared/tsconfig.build.json create mode 100644 apps/x/packages/shared/vitest.config.ts diff --git a/apps/x/packages/shared/package.json b/apps/x/packages/shared/package.json index 0bab918a..5a1a9c45 100644 --- a/apps/x/packages/shared/package.json +++ b/apps/x/packages/shared/package.json @@ -5,10 +5,15 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "rm -rf dist && tsc", - "dev": "tsc -w" + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "dev": "tsc -w", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "zod": "^4.2.1" + }, + "devDependencies": { + "vitest": "catalog:" } } \ No newline at end of file diff --git a/apps/x/packages/shared/src/sessions.test.ts b/apps/x/packages/shared/src/sessions.test.ts new file mode 100644 index 00000000..2676a2b7 --- /dev/null +++ b/apps/x/packages/shared/src/sessions.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + SessionCorruptionError, + SessionCreated, + SessionEvent, + SessionTurnAppended, + reduceSession, + sessionIndexEntry, +} from "./sessions.js"; + +type SEvent = z.infer; + +const SESSION_ID = "2026-07-02T09-00-00Z-0000042-000"; +const MODEL = { provider: "openai", model: "gpt-test" }; + +function sessionCreated( + overrides: Partial> = {}, +): z.infer { + return { + type: "session_created", + schemaVersion: 1, + sessionId: SESSION_ID, + ts: "2026-07-02T09:00:00Z", + ...overrides, + }; +} + +function turnAppended( + sessionSeq: number, + turnId: string, + overrides: Partial> = {}, +): z.infer { + return { + type: "turn_appended", + sessionId: SESSION_ID, + ts: `2026-07-02T09:0${sessionSeq}:00Z`, + turnId, + sessionSeq, + agentId: "copilot", + model: MODEL, + ...overrides, + }; +} + +function titleChanged(title: string, ts = "2026-07-02T09:05:00Z") { + return { + type: "title_changed" as const, + sessionId: SESSION_ID, + ts, + title, + }; +} + +function expectCorruption(events: SEvent[], match: string | RegExp): void { + expect(() => reduceSession(events)).toThrowError(SessionCorruptionError); + expect(() => reduceSession(events)).toThrowError(match); +} + +describe("schemas", () => { + it("session events round-trip through the SessionEvent schema", () => { + const events: SEvent[] = [ + sessionCreated({ title: "Hello" }), + turnAppended(1, "turn-1"), + titleChanged("Renamed"), + ]; + for (const event of events) { + expect(SessionEvent.parse(event)).toEqual(event); + } + }); + + it("rejects non-positive sessionSeq at the schema level", () => { + expect(() => SessionEvent.parse(turnAppended(0, "turn-0"))).toThrowError(); + }); +}); + +describe("reduceSession", () => { + it("reduces a created-only log", () => { + const state = reduceSession([sessionCreated()]); + expect(state.definition.sessionId).toBe(SESSION_ID); + expect(state.turns).toEqual([]); + expect(state.latestTurnId).toBeUndefined(); + expect(state.title).toBeUndefined(); + expect(state.createdAt).toBe("2026-07-02T09:00:00Z"); + expect(state.updatedAt).toBe("2026-07-02T09:00:00Z"); + }); + + it("folds turns in order with denormalized metadata", () => { + const state = reduceSession([ + sessionCreated(), + turnAppended(1, "turn-1"), + turnAppended(2, "turn-2", { agentId: "researcher", model: { provider: "anthropic", model: "claude-x" } }), + ]); + expect(state.turns).toHaveLength(2); + expect(state.turns[0]).toMatchObject({ turnId: "turn-1", sessionSeq: 1, agentId: "copilot" }); + expect(state.turns[1]).toMatchObject({ + turnId: "turn-2", + sessionSeq: 2, + agentId: "researcher", + model: { provider: "anthropic", model: "claude-x" }, + }); + expect(state.latestTurnId).toBe("turn-2"); + expect(state.updatedAt).toBe("2026-07-02T09:02:00Z"); + }); + + it("folds titles: creation default then last change wins", () => { + const withDefault = reduceSession([sessionCreated({ title: "First message…" })]); + expect(withDefault.title).toBe("First message…"); + + const renamed = reduceSession([ + sessionCreated({ title: "First message…" }), + titleChanged("Better title"), + titleChanged("Best title", "2026-07-02T09:06:00Z"), + ]); + expect(renamed.title).toBe("Best title"); + expect(renamed.updatedAt).toBe("2026-07-02T09:06:00Z"); + }); + + it("rejects an empty log", () => { + expectCorruption([], /empty/); + }); + + it("rejects a log not starting with session_created", () => { + expectCorruption([turnAppended(1, "turn-1")], /first event must be session_created/); + }); + + it("rejects duplicate session_created", () => { + expectCorruption([sessionCreated(), sessionCreated()], /duplicate session_created/); + }); + + it("rejects mismatched session ids", () => { + expectCorruption( + [sessionCreated(), { ...turnAppended(1, "turn-1"), sessionId: "other" }], + /does not match session/, + ); + }); + + it("rejects unsupported schema versions", () => { + const bad = { ...sessionCreated(), schemaVersion: 2 } as unknown as SEvent; + expectCorruption([bad], /unsupported session schema version/); + }); + + it("rejects unknown event types", () => { + const bad = { type: "wat", sessionId: SESSION_ID, ts: "t" } as unknown as SEvent; + expectCorruption([sessionCreated(), bad], /unknown session event type/); + }); + + it("rejects a first turn not at sessionSeq 1", () => { + expectCorruption([sessionCreated(), turnAppended(2, "turn-2")], /out of order; expected 1/); + }); + + it("rejects gapped sessionSeq", () => { + expectCorruption( + [sessionCreated(), turnAppended(1, "turn-1"), turnAppended(3, "turn-3")], + /out of order; expected 2/, + ); + }); + + it("rejects duplicate sessionSeq", () => { + expectCorruption( + [sessionCreated(), turnAppended(1, "turn-1"), turnAppended(1, "turn-1b")], + /out of order; expected 2/, + ); + }); + + it("rejects duplicate turn ids", () => { + expectCorruption( + [sessionCreated(), turnAppended(1, "turn-1"), turnAppended(2, "turn-1")], + /duplicate turnId/, + ); + }); +}); + +describe("sessionIndexEntry", () => { + it("projects an entry with last-turn metadata and the provided status", () => { + const state = reduceSession([ + sessionCreated({ title: "T" }), + turnAppended(1, "turn-1"), + turnAppended(2, "turn-2", { agentId: "researcher" }), + ]); + expect(sessionIndexEntry(state, "suspended")).toEqual({ + sessionId: SESSION_ID, + title: "T", + createdAt: "2026-07-02T09:00:00Z", + updatedAt: "2026-07-02T09:02:00Z", + turnCount: 2, + lastAgentId: "researcher", + lastModel: MODEL, + latestTurnId: "turn-2", + latestTurnStatus: "suspended", + }); + }); + + it("projects an empty session with status none", () => { + const state = reduceSession([sessionCreated()]); + const entry = sessionIndexEntry(state, "none"); + expect(entry.turnCount).toBe(0); + expect(entry.lastAgentId).toBeUndefined(); + expect(entry.latestTurnStatus).toBe("none"); + }); +}); diff --git a/apps/x/packages/shared/src/sessions.ts b/apps/x/packages/shared/src/sessions.ts new file mode 100644 index 00000000..52cfd11b --- /dev/null +++ b/apps/x/packages/shared/src/sessions.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; +import { ModelDescriptor, type TurnStatus } from "./turns.js"; + +// Durable session contract for the session layer (see +// packages/core/docs/session-design.md). A session is an append-only chain +// of turn references plus presentation metadata; conversation content lives +// exclusively in turn files. Pure module shared by core and renderer. + +// --------------------------------------------------------------------------- +// Durable events +// --------------------------------------------------------------------------- + +export const SessionCreated = z.object({ + type: z.literal("session_created"), + schemaVersion: z.literal(1), + sessionId: z.string(), + ts: z.string(), + title: z.string().optional(), +}); + +// agentId/model are denormalized from the turn so the session index can fold +// without opening turn files; the turn file stays authoritative. +export const SessionTurnAppended = z.object({ + type: z.literal("turn_appended"), + sessionId: z.string(), + ts: z.string(), + turnId: z.string(), + sessionSeq: z.number().int().positive(), + agentId: z.string(), + model: ModelDescriptor, +}); + +export const SessionTitleChanged = z.object({ + type: z.literal("title_changed"), + sessionId: z.string(), + ts: z.string(), + title: z.string(), +}); + +export const SessionEvent = z.discriminatedUnion("type", [ + SessionCreated, + SessionTurnAppended, + SessionTitleChanged, +]); + +// --------------------------------------------------------------------------- +// Derived session state +// --------------------------------------------------------------------------- + +export class SessionCorruptionError extends Error { + constructor(message: string) { + super(message); + this.name = "SessionCorruptionError"; + } +} + +export interface SessionTurnRef { + turnId: string; + sessionSeq: number; + agentId: string; + model: z.infer; + ts: string; +} + +export interface SessionState { + definition: z.infer; + title?: string; + turns: SessionTurnRef[]; + latestTurnId?: string; + createdAt: string; + updatedAt: string; +} + +function fail(message: string): never { + throw new SessionCorruptionError(message); +} + +export function reduceSession( + events: Array>, +): SessionState { + if (events.length === 0) { + fail("session log is empty"); + } + const [first, ...rest] = events; + if (first.type !== "session_created") { + fail(`first event must be session_created, got ${first.type}`); + } + if (first.schemaVersion !== 1) { + fail(`unsupported session schema version: ${String(first.schemaVersion)}`); + } + + const state: SessionState = { + definition: first, + title: first.title, + turns: [], + createdAt: first.ts, + updatedAt: first.ts, + }; + + for (const event of rest) { + if (event.sessionId !== first.sessionId) { + fail( + `event sessionId ${event.sessionId} does not match session ${first.sessionId}`, + ); + } + switch (event.type) { + case "session_created": + fail("duplicate session_created event"); + break; + case "turn_appended": { + const expectedSeq = state.turns.length + 1; + if (event.sessionSeq !== expectedSeq) { + fail( + `sessionSeq ${event.sessionSeq} out of order; expected ${expectedSeq}`, + ); + } + if (state.turns.some((t) => t.turnId === event.turnId)) { + fail(`duplicate turnId in session: ${event.turnId}`); + } + state.turns.push({ + turnId: event.turnId, + sessionSeq: event.sessionSeq, + agentId: event.agentId, + model: event.model, + ts: event.ts, + }); + state.latestTurnId = event.turnId; + break; + } + case "title_changed": + state.title = event.title; + break; + default: { + const unknown: never = event; + fail( + `unknown session event type: ${(unknown as { type: string }).type}`, + ); + } + } + state.updatedAt = event.ts; + } + + return state; +} + +// --------------------------------------------------------------------------- +// Session index (in-memory projection; never a source of truth) +// --------------------------------------------------------------------------- + +// "none" = the session has no turns yet. The remaining values are the latest +// turn's derived status (deriveTurnStatus in turns.ts). +export type SessionLatestTurnStatus = "none" | TurnStatus; + +export interface SessionIndexEntry { + sessionId: string; + title?: string; + createdAt: string; + updatedAt: string; + turnCount: number; + lastAgentId?: string; + lastModel?: z.infer; + latestTurnId?: string; + latestTurnStatus: SessionLatestTurnStatus; + // Set when the session (or its latest turn) failed to load/validate; the + // entry is surfaced in an errored state instead of aborting the startup + // scan. + error?: string; +} + +export function sessionIndexEntry( + state: SessionState, + latestTurnStatus: SessionLatestTurnStatus, +): SessionIndexEntry { + const lastTurn = state.turns[state.turns.length - 1]; + return { + sessionId: state.definition.sessionId, + title: state.title, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + turnCount: state.turns.length, + lastAgentId: lastTurn?.agentId, + lastModel: lastTurn?.model, + latestTurnId: state.latestTurnId, + latestTurnStatus, + }; +} diff --git a/apps/x/packages/shared/src/turns.test.ts b/apps/x/packages/shared/src/turns.test.ts new file mode 100644 index 00000000..bcdae400 --- /dev/null +++ b/apps/x/packages/shared/src/turns.test.ts @@ -0,0 +1,1337 @@ +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; + +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 = { + toolId: "tool.echo", + name: "echo", + description: "Echo tool", + inputSchema: {}, + execution: "sync", + requiresHuman: false, +}; + +const fetchTool: z.infer = { + 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>) { + 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 { + 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["request"]["messages"], + requestOverrides: Partial["request"]> = {}, +): z.infer { + return { + type: "model_call_requested", + turnId: TURN_ID, + ts: TS, + modelCallIndex: index, + request: { + systemPrompt: "SYS", + messages, + tools: [echoTool, fetchTool], + parameters: {}, + ...requestOverrides, + }, + }; +} + +function completed( + index: number, + message: z.infer["message"], + usage: z.infer["usage"] = { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, +): z.infer { + 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 { + return { + type: "model_call_failed", + turnId: TURN_ID, + ts: TS, + modelCallIndex: index, + error, + }; +} + +function stepEvent( + index: number, + event: z.infer["event"] = { + type: "text_end", + text: "chunk", + }, +): z.infer { + return { + type: "model_step_event", + turnId: TURN_ID, + ts: TS, + modelCallIndex: index, + event, + }; +} + +function permRequired( + id: string, + name: string, + checkerError?: string, +): z.infer { + 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["decision"], +): z.infer { + return { + type: "tool_permission_classified", + turnId: TURN_ID, + ts: TS, + toolCallId: id, + decision, + reason: "because", + }; +} + +function permClassificationFailed( + ids: string[], +): z.infer { + return { + type: "tool_permission_classification_failed", + turnId: TURN_ID, + ts: TS, + toolCallIds: ids, + error: "classifier timed out", + }; +} + +function permResolved( + id: string, + decision: z.infer["decision"], + source: z.infer["source"] = "human", +): z.infer { + return { + type: "tool_permission_resolved", + turnId: TURN_ID, + ts: TS, + toolCallId: id, + decision, + source, + }; +} + +function invocation( + id: string, + tool: z.infer = echoTool, + overrides: Partial> = {}, +): z.infer { + 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["source"] = "sync", +): z.infer { + return { + type: "tool_progress", + turnId: TURN_ID, + ts: TS, + toolCallId: id, + source, + progress: { pct: 50 }, + }; +} + +function result( + id: string, + name: string, + source: z.infer["source"] = "sync", + output: JsonValue = "ok", + isError = false, +): z.infer { + 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 }>, +): z.infer { + 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["output"] = assistantText("done"), +): z.infer { + return { + type: "turn_completed", + turnId: TURN_ID, + ts: TS, + output, + finishReason: "stop", + usage: {}, + }; +} + +function turnFailedEv( + error = "it broke", + code?: string, +): z.infer { + return { + type: "turn_failed", + turnId: TURN_ID, + ts: TS, + error, + ...(code === undefined ? {} : { code }), + usage: {}, + }; +} + +function turnCancelledEv(reason?: string): z.infer { + 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, [user("hello")]), + completed(0, call0), + permRequired("tc1", "echo"), + permResolved("tc1", "allow"), + invocation("tc1"), + result("tc1", "echo"), + requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]), + 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["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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + completed(0, call0), + permRequired("tc1", "echo"), + permClassificationFailed(["tc1"]), + permResolved("tc1", "deny", "human"), + result("tc1", "echo", "runtime", "Permission denied", 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")], { + 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, [user("hello")]), + ], + /contextRef inconsistent/, + ); + }); + + it("rejects a request whose contextRef targets the wrong turn", () => { + expectCorruption( + [ + created({ context: { previousTurnId: PREV_TURN_ID } }), + requested(0, [user("hello")], { + contextRef: { previousTurnId: "some-other-turn" }, + }), + ], + /contextRef inconsistent/, + ); + }); + + it("rejects a contextRef when the turn context is inline", () => { + expectCorruption( + [ + created(), + requested(0, [user("hello")], { + contextRef: { previousTurnId: PREV_TURN_ID }, + }), + ], + /contextRef but turn context is inline/, + ); + }); + + it("ignores tool messages inside an inline context prefix when checking order", () => { + const context = [ + user("earlier"), + assistantCalls(toolCallPart("old1", "echo")), + toolMsg("old1", "echo"), + ]; + const state = reduceTurn([ + created({ context }), + requested(0, [...context, user("hello")]), + completed(0, assistantText("done")), + turnCompletedEv(), + ]); + expect(deriveTurnStatus(state)).toBe("completed"); + }); +}); + +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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [ + user("hello"), + call0, + toolMsg("a1", "fetch", "first"), + toolMsg("a2", "fetch", "second"), + ]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + callFailed(0, "interrupted by process restart"), + requested(1, [user("hello")]), + 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, [user("hello")]), + callFailed(0, "interrupted"), + requested(1, [user("hello")]), + ], + /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, [user("hello")]), + 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, [user("hello"), call0, toolMsg("tc1", "echo", "…")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")])], /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, [user("hello")]), 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( + [created(), requested(1, [user("hello")])], + /out of order/, + ); + }); + + it("rejects a reused model call index", () => { + expectCorruption( + [ + created(), + requested(0, [user("hello")]), + completed(0, assistantText("a")), + requested(0, [user("hello")]), + ], + /out of order/, + ); + }); + + it("rejects concurrent unresolved model requests", () => { + expectCorruption( + [created(), requested(0, [user("hello")]), requested(1, [user("hello")])], + /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, [user("hello")]), + completed(0, assistantText("a")), + completed(0, assistantText("b")), + ], + /duplicate settlement/, + ); + }); + + it("rejects completion after failure of the same call", () => { + expectCorruption( + [ + created(), + requested(0, [user("hello")]), + 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, [user("hello")]), + completed(0, call0), + requested(1, [user("hello"), call0]), + ], + /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, [user("hello")]), + completed(0, call0), + invocation("tc1"), + result("tc1", "echo"), + requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]), + ], + /exceeds maxModelCalls/, + ); + }); + + it("rejects duplicate tool call ids within one response", () => { + expectCorruption( + [ + created(), + requested(0, [user("hello")]), + 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, [user("hello")]), + completed(0, call0), + invocation("tc1"), + result("tc1", "echo"), + requested(1, [user("hello"), call0, toolMsg("tc1", "echo")]), + completed(1, assistantCalls(toolCallPart("tc1", "echo"))), + ], + /duplicate tool call id/, + ); + }); + + it("rejects tool-result ordering that differs from original call order", () => { + const call0 = assistantCalls( + toolCallPart("tc1", "echo"), + toolCallPart("tc2", "echo"), + ); + expectCorruption( + [ + created(), + requested(0, [user("hello")]), + completed(0, call0), + invocation("tc1"), + result("tc1", "echo"), + invocation("tc2"), + result("tc2", "echo"), + requested(1, [ + user("hello"), + call0, + toolMsg("tc2", "echo"), + toolMsg("tc1", "echo"), + ]), + ], + /tool-result order differs/, + ); + }); + + it("rejects permission records targeting unknown tool calls", () => { + expectCorruption( + [created(), requested(0, [user("hello")]), 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), 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, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), 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, [user("hello")]), + 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, [user("hello")]), completed(0, call0), turnFailedEv()], + /failure while tool calls lack terminal results/, + ); + }); + + it("rejects terminal events while a model call is unsettled", () => { + expectCorruption( + [created(), requested(0, [user("hello")]), turnCancelledEv()], + /cancellation while a model call is unsettled/, + ); + }); + + it("rejects any event after a terminal event", () => { + const base = [ + created(), + requested(0, [user("hello")]), + 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, [user("hello")]), + 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, [user("hello")]), + completed(0, call0), + permRequired("tc1", "echo"), + suspendedEv([{ id: "tc1", name: "echo" }], []), + ]); + expect(deriveTurnStatus(pending)).toBe("suspended"); + + const idle = reduceTurn([created(), requested(0, [user("hello")]), completed(0, call0)]); + 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, [user("hello")]), + completed(0, call0), + invocation("tc1"), + invocation("tc2"), + result("tc2", "echo", "sync", { n: 2 }), + result("tc1", "echo", "sync", "plain text"), + requested(1, [ + user("hello"), + call0, + toolMsg("tc1", "echo", "plain text"), + toolMsg("tc2", "echo", '{"n":2}'), + ]), + 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, [user("hello")]), + callFailed(0, "interrupted"), + requested(1, [user("hello")]), + 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, [user("hello")], { + 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, [user("hello")]), + 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 new file mode 100644 index 00000000..52e39c23 --- /dev/null +++ b/apps/x/packages/shared/src/turns.ts @@ -0,0 +1,958 @@ +import { z } from "zod"; +import { + AssistantMessage, + ToolCallPart, + ToolMessage, + UserMessage, +} from "./message.js"; + +// Durable turn contract for the turn runtime (see +// packages/core/docs/turn-runtime-design.md). This module is the +// cross-boundary source of truth shared by core and renderer: event schemas, +// the pure reducer, and pure derivations over the reduced state. It must +// stay free of I/O and node-only imports so the renderer can consume it +// directly. + +export type JsonValue = z.infer>; + +export const DEFAULT_MAX_MODEL_CALLS = 20; +export const MODEL_CALL_LIMIT_ERROR_CODE = "model-call-limit"; + +// --------------------------------------------------------------------------- +// Agent snapshot +// --------------------------------------------------------------------------- + +export const ModelDescriptor = z.object({ + provider: z.string(), + model: z.string(), +}); + +export const RequestedAgent = z.object({ + agentId: z.string(), + overrides: z + .object({ + model: ModelDescriptor.optional(), + }) + .optional(), +}); + +export const ToolDescriptor = z.object({ + toolId: z.string(), + name: z.string(), + description: z.string(), + inputSchema: z.json(), + execution: z.enum(["sync", "async"]), + requiresHuman: z.boolean(), +}); + +export const ResolvedAgent = z.object({ + agentId: z.string(), + systemPrompt: z.string(), + model: ModelDescriptor, + tools: z.array(ToolDescriptor), +}); + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +// Context excludes system-role messages: the resolved agent owns the single +// authoritative system prompt. +export const ConversationMessage = z.discriminatedUnion("role", [ + UserMessage, + AssistantMessage, + ToolMessage, +]); + +// A reference points at the immediately preceding turn; its materialized +// value is that turn's transcript (context + input + produced messages). +// Inline arrays are used by standalone turns and by callers that assemble +// context themselves. Resolution is a runtime concern; the reducer treats +// context as opaque. +export const TurnContextRef = z.object({ + previousTurnId: z.string(), +}); + +export const TurnContext = z.union([ + TurnContextRef, + z.array(ConversationMessage), +]); + +// --------------------------------------------------------------------------- +// Usage +// --------------------------------------------------------------------------- + +export const TurnUsage = z.object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalTokens: z.number().optional(), + reasoningTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), +}); + +// --------------------------------------------------------------------------- +// Durable events +// --------------------------------------------------------------------------- + +export const TurnCreated = z.object({ + type: z.literal("turn_created"), + schemaVersion: z.literal(1), + turnId: z.string(), + ts: z.string(), + sessionId: z.string().nullable(), + agent: z.object({ + requested: RequestedAgent, + resolved: ResolvedAgent, + }), + context: TurnContext, + input: UserMessage, + config: z.object({ + autoPermission: z.boolean(), + humanAvailable: z.boolean(), + maxModelCalls: z.number().int().positive(), + }), +}); + +// `messages` contains current-turn messages only; when the turn's context is +// a reference, `contextRef` repeats it and the resolved prefix is implicit +// (deterministically materializable because referenced turns are immutable). +// When context is inline, `contextRef` is absent and `messages` begins with +// the inline context. +export const ModelRequest = z.object({ + systemPrompt: z.string(), + contextRef: TurnContextRef.optional(), + messages: z.array(ConversationMessage), + tools: z.array(ToolDescriptor), + parameters: z.record(z.string(), z.json()), +}); + +export const ModelCallRequested = z.object({ + type: z.literal("model_call_requested"), + turnId: z.string(), + ts: z.string(), + modelCallIndex: z.number().int().nonnegative(), + request: ModelRequest, +}); + +// Normalized provider step events kept for debugging. Raw text/reasoning +// deltas are stream-only and never appear here. +export const DurableLlmStepStreamEvent = z.discriminatedUnion("type", [ + z.object({ type: z.literal("text_start") }), + z.object({ type: z.literal("text_end"), text: z.string() }), + z.object({ type: z.literal("reasoning_start") }), + z.object({ type: z.literal("reasoning_end"), text: z.string() }), + z.object({ type: z.literal("tool_call"), toolCall: ToolCallPart }), + z.object({ + type: z.literal("finish_step"), + finishReason: z.string(), + usage: TurnUsage.optional(), + providerMetadata: z.json().optional(), + }), + z.object({ type: z.literal("provider_error"), error: z.string() }), +]); + +export const ModelStepEvent = z.object({ + type: z.literal("model_step_event"), + turnId: z.string(), + ts: z.string(), + modelCallIndex: z.number().int().nonnegative(), + event: DurableLlmStepStreamEvent, +}); + +export const ModelCallCompleted = z.object({ + type: z.literal("model_call_completed"), + turnId: z.string(), + ts: z.string(), + modelCallIndex: z.number().int().nonnegative(), + message: AssistantMessage, + finishReason: z.string(), + usage: TurnUsage, + providerMetadata: z.json().optional(), +}); + +export const ModelCallFailed = z.object({ + type: z.literal("model_call_failed"), + turnId: z.string(), + ts: z.string(), + modelCallIndex: z.number().int().nonnegative(), + error: z.string(), +}); + +export const ToolPermissionRequired = z.object({ + type: z.literal("tool_permission_required"), + turnId: z.string(), + ts: z.string(), + toolCallId: z.string(), + toolName: z.string(), + request: z.json(), + checkerError: z.string().optional(), +}); + +export const ToolPermissionClassified = z.object({ + type: z.literal("tool_permission_classified"), + turnId: z.string(), + ts: z.string(), + toolCallId: z.string(), + decision: z.enum(["allow", "deny", "defer"]), + reason: z.string(), +}); + +export const ToolPermissionClassificationFailed = z.object({ + type: z.literal("tool_permission_classification_failed"), + turnId: z.string(), + ts: z.string(), + toolCallIds: z.array(z.string()), + error: z.string(), +}); + +// The only effective execution decision; classifier records are provenance. +export const ToolPermissionResolved = z.object({ + type: z.literal("tool_permission_resolved"), + turnId: z.string(), + ts: z.string(), + toolCallId: z.string(), + decision: z.enum(["allow", "deny"]), + source: z.enum(["classifier", "human", "human_unavailable"]), + reason: z.string().optional(), + metadata: z.json().optional(), +}); + +export const ToolInvocationRequested = z.object({ + type: z.literal("tool_invocation_requested"), + turnId: z.string(), + ts: z.string(), + toolCallId: z.string(), + toolId: z.string(), + toolName: z.string(), + execution: z.enum(["sync", "async"]), + input: z.json(), +}); + +export const ToolProgress = z.object({ + type: z.literal("tool_progress"), + turnId: z.string(), + ts: z.string(), + toolCallId: z.string(), + source: z.enum(["sync", "async"]), + progress: z.json(), +}); + +export const ToolResultData = z.object({ + output: z.json(), + isError: z.boolean(), + metadata: z.json().optional(), +}); + +// `runtime` results cover permission denial, unknown tools, unusable calls, +// human unavailability, cancellation, and interrupted sync execution. A +// result may exist without an invocation when execution was rejected before +// dispatch. +export const ToolResult = z.object({ + type: z.literal("tool_result"), + turnId: z.string(), + ts: z.string(), + toolCallId: z.string(), + toolName: z.string(), + source: z.enum(["sync", "async", "runtime"]), + result: ToolResultData, +}); + +export const TurnSuspended = z.object({ + type: z.literal("turn_suspended"), + turnId: z.string(), + ts: z.string(), + pendingPermissions: z.array( + z.object({ + toolCallId: z.string(), + toolName: z.string(), + request: z.json(), + }), + ), + pendingAsyncTools: z.array( + z.object({ + toolCallId: z.string(), + toolId: z.string(), + toolName: z.string(), + input: z.json(), + }), + ), + usage: TurnUsage, +}); + +export const TurnCompleted = z.object({ + type: z.literal("turn_completed"), + turnId: z.string(), + ts: z.string(), + output: AssistantMessage, + finishReason: z.string(), + usage: TurnUsage, +}); + +export const TurnFailed = z.object({ + type: z.literal("turn_failed"), + turnId: z.string(), + ts: z.string(), + error: z.string(), + // Machine-readable discriminator for failures callers must tell apart; + // MODEL_CALL_LIMIT_ERROR_CODE is defined by the spec. + code: z.string().optional(), + usage: TurnUsage, +}); + +export const TurnCancelled = z.object({ + type: z.literal("turn_cancelled"), + turnId: z.string(), + ts: z.string(), + reason: z.string().optional(), + usage: TurnUsage, +}); + +export const TurnEvent = z.discriminatedUnion("type", [ + TurnCreated, + ModelCallRequested, + ModelStepEvent, + ModelCallCompleted, + ModelCallFailed, + ToolPermissionRequired, + ToolPermissionClassified, + ToolPermissionClassificationFailed, + ToolPermissionResolved, + ToolInvocationRequested, + ToolProgress, + ToolResult, + TurnSuspended, + TurnCompleted, + TurnFailed, + TurnCancelled, +]); + +// --------------------------------------------------------------------------- +// Ephemeral stream-only deltas (never persisted) +// --------------------------------------------------------------------------- + +export type TextDelta = { + type: "text_delta"; + turnId: string; + modelCallIndex: number; + delta: string; +}; + +export type ReasoningDelta = { + type: "reasoning_delta"; + turnId: string; + modelCallIndex: number; + delta: string; +}; + +export type TurnStreamEvent = + | z.infer + | TextDelta + | ReasoningDelta; + +// --------------------------------------------------------------------------- +// Derived turn state +// --------------------------------------------------------------------------- + +export class TurnCorruptionError extends Error { + constructor(message: string) { + super(message); + this.name = "TurnCorruptionError"; + } +} + +export interface ModelCallState { + index: number; + request: z.infer; + stepEvents: Array>; + response?: z.infer; + finishReason?: string; + usage?: z.infer; + providerMetadata?: JsonValue; + error?: string; +} + +export interface ToolCallState { + modelCallIndex: number; + order: number; + toolCallId: string; + toolName: string; + input: unknown; + toolId?: string; + execution?: "sync" | "async"; + permission?: { + required: z.infer; + classification?: z.infer; + resolved?: z.infer; + }; + invocation?: z.infer; + progress: Array>; + result?: z.infer; +} + +export interface TurnState { + definition: z.infer; + modelCalls: ModelCallState[]; + toolCalls: ToolCallState[]; + suspension?: z.infer; + terminal?: + | z.infer + | z.infer + | z.infer; + usage: z.infer; +} + +function fail(message: string): never { + throw new TurnCorruptionError(message); +} + +function isContextRef( + context: z.infer, +): context is z.infer { + return !Array.isArray(context); +} + +function findToolCall(state: TurnState, toolCallId: string): ToolCallState { + const toolCall = state.toolCalls.find( + (tc) => tc.toolCallId === toolCallId, + ); + if (!toolCall) { + fail(`event targets unknown tool call: ${toolCallId}`); + } + return toolCall; +} + +function openModelCall(state: TurnState): ModelCallState | undefined { + const last = state.modelCalls[state.modelCalls.length - 1]; + if (last && last.response === undefined && last.error === undefined) { + return last; + } + return undefined; +} + +function batchToolCalls(state: TurnState, modelCallIndex: number): ToolCallState[] { + return state.toolCalls + .filter((tc) => tc.modelCallIndex === modelCallIndex) + .sort((a, b) => a.order - b.order); +} + +function addUsage( + total: z.infer, + usage: z.infer, +): void { + const keys = [ + "inputTokens", + "outputTokens", + "totalTokens", + "reasoningTokens", + "cachedInputTokens", + ] as const; + for (const key of keys) { + const value = usage[key]; + if (value !== undefined) { + total[key] = (total[key] ?? 0) + value; + } + } +} + +function applyModelCallRequested( + state: TurnState, + event: z.infer, +): void { + const expectedIndex = state.modelCalls.length; + if (event.modelCallIndex !== expectedIndex) { + fail( + `model call index ${event.modelCallIndex} out of order; expected ${expectedIndex}`, + ); + } + if (expectedIndex >= state.definition.config.maxModelCalls) { + fail( + `model call index ${event.modelCallIndex} exceeds maxModelCalls ${state.definition.config.maxModelCalls}`, + ); + } + if (openModelCall(state)) { + fail("concurrent unresolved model call requests"); + } + const unresolved = state.toolCalls.filter((tc) => !tc.result); + if (unresolved.length > 0) { + fail( + `model call requested while tool calls are unresolved: ${unresolved + .map((tc) => tc.toolCallId) + .join(", ")}`, + ); + } + + const context = state.definition.context; + if (isContextRef(context)) { + if (event.request.contextRef?.previousTurnId !== context.previousTurnId) { + fail("model request contextRef inconsistent with turn context"); + } + } else if (event.request.contextRef !== undefined) { + fail("model request has contextRef but turn context is inline"); + } + + // Model-facing tool-result order must match original call order: the tool + // messages in the request (past any inline context prefix) must be the + // concatenated batches of prior completed calls, in source order. + const prefixLength = isContextRef(context) ? 0 : context.length; + const toolMessageIds = event.request.messages + .slice(prefixLength) + .filter((m) => m.role === "tool") + .map((m) => m.toolCallId); + const expectedIds = state.modelCalls + .filter((call) => call.response !== undefined) + .flatMap((call) => + batchToolCalls(state, call.index).map((tc) => tc.toolCallId), + ); + if ( + toolMessageIds.length !== expectedIds.length || + toolMessageIds.some((id, i) => id !== expectedIds[i]) + ) { + fail("model request tool-result order differs from original call order"); + } + + state.modelCalls.push({ + index: event.modelCallIndex, + request: event.request, + stepEvents: [], + }); +} + +function applyModelStepEvent( + state: TurnState, + event: z.infer, +): void { + const call = state.modelCalls[event.modelCallIndex]; + if (!call) { + fail(`step event without matching model call request: ${event.modelCallIndex}`); + } + if (call.response !== undefined || call.error !== undefined) { + fail(`step event after model call ${event.modelCallIndex} settled`); + } + call.stepEvents.push(event.event); +} + +function applyModelCallCompleted( + state: TurnState, + event: z.infer, +): void { + const call = state.modelCalls[event.modelCallIndex]; + if (!call) { + fail(`model call completion without matching request: ${event.modelCallIndex}`); + } + if (call.response !== undefined || call.error !== undefined) { + fail(`duplicate settlement for model call ${event.modelCallIndex}`); + } + call.response = event.message; + call.finishReason = event.finishReason; + call.usage = event.usage; + call.providerMetadata = event.providerMetadata; + addUsage(state.usage, event.usage); + + const parts = Array.isArray(event.message.content) + ? event.message.content + : []; + let order = 0; + for (const part of parts) { + if (part.type !== "tool-call") { + continue; + } + if (state.toolCalls.some((tc) => tc.toolCallId === part.toolCallId)) { + fail(`duplicate tool call id: ${part.toolCallId}`); + } + const descriptor = state.definition.agent.resolved.tools.find( + (tool) => tool.name === part.toolName, + ); + state.toolCalls.push({ + modelCallIndex: event.modelCallIndex, + order: order++, + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.arguments, + toolId: descriptor?.toolId, + execution: descriptor?.execution, + progress: [], + }); + } +} + +function applyModelCallFailed( + state: TurnState, + event: z.infer, +): void { + const call = state.modelCalls[event.modelCallIndex]; + if (!call) { + fail(`model call failure without matching request: ${event.modelCallIndex}`); + } + if (call.response !== undefined || call.error !== undefined) { + fail(`duplicate settlement for model call ${event.modelCallIndex}`); + } + call.error = event.error; +} + +function applyToolPermissionRequired( + state: TurnState, + event: z.infer, +): void { + const toolCall = findToolCall(state, event.toolCallId); + if (toolCall.result) { + fail(`permission requirement after tool result: ${event.toolCallId}`); + } + if (toolCall.invocation) { + fail(`permission requirement after invocation: ${event.toolCallId}`); + } + if (toolCall.permission) { + fail(`duplicate permission requirement: ${event.toolCallId}`); + } + toolCall.permission = { required: event }; +} + +function applyToolPermissionClassified( + state: TurnState, + event: z.infer, +): void { + const toolCall = findToolCall(state, event.toolCallId); + if (!toolCall.permission) { + fail(`classification without permission requirement: ${event.toolCallId}`); + } + if (toolCall.permission.resolved) { + fail(`classification after permission resolution: ${event.toolCallId}`); + } + if (toolCall.permission.classification) { + fail(`duplicate permission classification: ${event.toolCallId}`); + } + toolCall.permission.classification = event; +} + +function applyToolPermissionClassificationFailed( + state: TurnState, + event: z.infer, +): void { + for (const toolCallId of event.toolCallIds) { + const toolCall = findToolCall(state, toolCallId); + if (!toolCall.permission) { + fail(`classification failure without permission requirement: ${toolCallId}`); + } + if (toolCall.permission.resolved) { + fail(`classification failure after permission resolution: ${toolCallId}`); + } + } + // Recorded for the audit trail; does not currently affect derived state. +} + +function applyToolPermissionResolved( + state: TurnState, + event: z.infer, +): void { + const toolCall = findToolCall(state, event.toolCallId); + if (!toolCall.permission) { + fail(`permission resolution without requirement: ${event.toolCallId}`); + } + if (toolCall.permission.resolved) { + fail(`conflicting permission decisions: ${event.toolCallId}`); + } + if (toolCall.result) { + fail(`permission resolution after tool result: ${event.toolCallId}`); + } + toolCall.permission.resolved = event; +} + +function applyToolInvocationRequested( + state: TurnState, + event: z.infer, +): void { + const toolCall = findToolCall(state, event.toolCallId); + if (toolCall.invocation) { + fail(`duplicate tool invocation: ${event.toolCallId}`); + } + if (toolCall.result) { + fail(`tool invocation after result: ${event.toolCallId}`); + } + if (toolCall.toolName !== event.toolName) { + fail(`tool invocation name mismatch: ${event.toolCallId}`); + } + if (toolCall.permission && toolCall.permission.resolved?.decision !== "allow") { + fail(`tool invocation without permission allowance: ${event.toolCallId}`); + } + if (toolCall.execution !== undefined && toolCall.execution !== event.execution) { + fail(`tool invocation execution mismatch: ${event.toolCallId}`); + } + if (toolCall.toolId !== undefined && toolCall.toolId !== event.toolId) { + fail(`tool invocation toolId mismatch: ${event.toolCallId}`); + } + toolCall.execution = event.execution; + toolCall.toolId = event.toolId; + toolCall.invocation = event; +} + +function applyToolProgress( + state: TurnState, + event: z.infer, +): void { + const toolCall = findToolCall(state, event.toolCallId); + if (!toolCall.invocation) { + fail(`tool progress without invocation: ${event.toolCallId}`); + } + if (toolCall.result) { + fail(`tool progress after terminal result: ${event.toolCallId}`); + } + if (event.source !== toolCall.execution) { + fail(`tool progress source mismatch: ${event.toolCallId}`); + } + toolCall.progress.push(event); +} + +function applyToolResult( + state: TurnState, + event: z.infer, +): void { + const toolCall = findToolCall(state, event.toolCallId); + if (toolCall.result) { + fail(`duplicate tool result: ${event.toolCallId}`); + } + if (toolCall.toolName !== event.toolName) { + fail(`tool result name mismatch: ${event.toolCallId}`); + } + if (event.source !== "runtime") { + if (!toolCall.invocation) { + fail(`${event.source} tool result without invocation: ${event.toolCallId}`); + } + if (event.source !== toolCall.execution) { + fail(`tool result source mismatch: ${event.toolCallId}`); + } + } + toolCall.result = event; +} + +function sortedIds(ids: string[]): string { + return [...ids].sort().join(","); +} + +function applyTurnSuspended( + state: TurnState, + event: z.infer, +): void { + if (openModelCall(state)) { + fail("suspension while a model call is unsettled"); + } + const expectedPermissions = outstandingPermissions(state).map( + (tc) => tc.toolCallId, + ); + const expectedAsync = outstandingAsyncTools(state).map((tc) => tc.toolCallId); + if (expectedPermissions.length + expectedAsync.length === 0) { + fail("suspension without pending external work"); + } + const claimedPermissions = event.pendingPermissions.map((p) => p.toolCallId); + const claimedAsync = event.pendingAsyncTools.map((p) => p.toolCallId); + if ( + sortedIds(claimedPermissions) !== sortedIds(expectedPermissions) || + sortedIds(claimedAsync) !== sortedIds(expectedAsync) + ) { + fail("suspension snapshot inconsistent with pending state"); + } + state.suspension = event; +} + +function assertTerminalPreconditions(state: TurnState, kind: string): void { + if (openModelCall(state)) { + fail(`${kind} while a model call is unsettled`); + } + const unresolved = state.toolCalls.filter((tc) => !tc.result); + if (unresolved.length > 0) { + fail( + `${kind} while tool calls lack terminal results: ${unresolved + .map((tc) => tc.toolCallId) + .join(", ")}`, + ); + } +} + +function applyTurnCompleted( + state: TurnState, + event: z.infer, +): void { + assertTerminalPreconditions(state, "completion"); + const last = state.modelCalls[state.modelCalls.length - 1]; + if (!last || last.response === undefined) { + fail("completion without a completed model response"); + } + if (batchToolCalls(state, last.index).length > 0) { + fail("completion while the final response has tool calls"); + } + state.terminal = event; +} + +export function reduceTurn( + events: Array>, +): TurnState { + if (events.length === 0) { + fail("turn log is empty"); + } + const [first, ...rest] = events; + if (first.type !== "turn_created") { + fail(`first event must be turn_created, got ${first.type}`); + } + if (first.schemaVersion !== 1) { + fail(`unsupported turn schema version: ${String(first.schemaVersion)}`); + } + + const state: TurnState = { + definition: first, + modelCalls: [], + toolCalls: [], + usage: {}, + }; + + for (const event of rest) { + if (event.turnId !== first.turnId) { + fail( + `event turnId ${event.turnId} does not match turn ${first.turnId}`, + ); + } + if (state.terminal) { + fail(`event after terminal turn event: ${event.type}`); + } + switch (event.type) { + case "turn_created": + fail("duplicate turn_created event"); + break; + case "model_call_requested": + applyModelCallRequested(state, event); + break; + case "model_step_event": + applyModelStepEvent(state, event); + break; + case "model_call_completed": + applyModelCallCompleted(state, event); + break; + case "model_call_failed": + applyModelCallFailed(state, event); + break; + case "tool_permission_required": + applyToolPermissionRequired(state, event); + break; + case "tool_permission_classified": + applyToolPermissionClassified(state, event); + break; + case "tool_permission_classification_failed": + applyToolPermissionClassificationFailed(state, event); + break; + case "tool_permission_resolved": + applyToolPermissionResolved(state, event); + break; + case "tool_invocation_requested": + applyToolInvocationRequested(state, event); + break; + case "tool_progress": + applyToolProgress(state, event); + break; + case "tool_result": + applyToolResult(state, event); + break; + case "turn_suspended": + applyTurnSuspended(state, event); + break; + case "turn_completed": + applyTurnCompleted(state, event); + break; + case "turn_failed": + assertTerminalPreconditions(state, "failure"); + state.terminal = event; + break; + case "turn_cancelled": + assertTerminalPreconditions(state, "cancellation"); + state.terminal = event; + break; + default: { + const unknown: never = event; + fail(`unknown turn event type: ${(unknown as { type: string }).type}`); + } + } + } + + return state; +} + +// --------------------------------------------------------------------------- +// Pure derivations over TurnState +// --------------------------------------------------------------------------- + +export function outstandingPermissions(state: TurnState): ToolCallState[] { + return state.toolCalls.filter( + (tc) => tc.permission && !tc.permission.resolved && !tc.result, + ); +} + +export function outstandingAsyncTools(state: TurnState): ToolCallState[] { + return state.toolCalls.filter( + (tc) => tc.invocation && tc.execution === "async" && !tc.result, + ); +} + +export type TurnStatus = + | "completed" + | "failed" + | "cancelled" + | "suspended" + | "idle"; + +// No durable running status exists by design: an "idle" turn is simply +// non-terminal with no outstanding external work. Whether it is actively +// being advanced right now is ephemeral bus state. +export function deriveTurnStatus(state: TurnState): TurnStatus { + if (state.terminal) { + switch (state.terminal.type) { + case "turn_completed": + return "completed"; + case "turn_failed": + return "failed"; + case "turn_cancelled": + return "cancelled"; + } + } + if ( + outstandingPermissions(state).length > 0 || + outstandingAsyncTools(state).length > 0 + ) { + return "suspended"; + } + return "idle"; +} + +function toolResultContent(result: z.infer): string { + const output = result.result.output; + return typeof output === "string" ? output : JSON.stringify(output); +} + +// The messages this turn contributed to the conversation: its input plus, per +// completed model call, the assistant response and its tool results in source +// order. Failed model calls contribute nothing. The turn's context prefix is +// NOT included; materializing the full conversation is the context resolver's +// job (core). Requires every tool call to have a terminal result, which holds +// for all terminal turns. +export function turnTranscript( + state: TurnState, +): Array> { + const messages: Array> = [ + state.definition.input, + ]; + for (const call of state.modelCalls) { + if (call.response === undefined) { + continue; + } + messages.push(call.response); + for (const toolCall of batchToolCalls(state, call.index)) { + if (!toolCall.result) { + throw new Error( + `turnTranscript requires terminal tool results; ${toolCall.toolCallId} is unresolved`, + ); + } + messages.push({ + role: "tool", + content: toolResultContent(toolCall.result), + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + }); + } + } + return messages; +} diff --git a/apps/x/packages/shared/tsconfig.build.json b/apps/x/packages/shared/tsconfig.build.json new file mode 100644 index 00000000..4838eeca --- /dev/null +++ b/apps/x/packages/shared/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/apps/x/packages/shared/vitest.config.ts b/apps/x/packages/shared/vitest.config.ts new file mode 100644 index 00000000..4064b801 --- /dev/null +++ b/apps/x/packages/shared/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + globals: false, + clearMocks: true, + restoreMocks: true, + }, +}); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5ad372b2..edd7e7b1 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -545,6 +545,10 @@ importers: zod: specifier: ^4.2.1 version: 4.2.1 + devDependencies: + vitest: + specifier: 'catalog:' + version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) packages: