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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 07:46:07 +05:30
parent 1825202e65
commit d821c419fe
8 changed files with 2711 additions and 2 deletions

View file

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

View file

@ -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<typeof SessionEvent>;
const SESSION_ID = "2026-07-02T09-00-00Z-0000042-000";
const MODEL = { provider: "openai", model: "gpt-test" };
function sessionCreated(
overrides: Partial<z.infer<typeof SessionCreated>> = {},
): z.infer<typeof SessionCreated> {
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<typeof SessionTurnAppended>> = {},
): z.infer<typeof SessionTurnAppended> {
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");
});
});

View file

@ -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<typeof ModelDescriptor>;
ts: string;
}
export interface SessionState {
definition: z.infer<typeof SessionCreated>;
title?: string;
turns: SessionTurnRef[];
latestTurnId?: string;
createdAt: string;
updatedAt: string;
}
function fail(message: string): never {
throw new SessionCorruptionError(message);
}
export function reduceSession(
events: Array<z.infer<typeof SessionEvent>>,
): 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<typeof ModelDescriptor>;
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,
};
}

File diff suppressed because it is too large Load diff

View file

@ -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<ReturnType<typeof z.json>>;
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<typeof TurnEvent>
| 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<typeof ModelRequest>;
stepEvents: Array<z.infer<typeof DurableLlmStepStreamEvent>>;
response?: z.infer<typeof AssistantMessage>;
finishReason?: string;
usage?: z.infer<typeof TurnUsage>;
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<typeof ToolPermissionRequired>;
classification?: z.infer<typeof ToolPermissionClassified>;
resolved?: z.infer<typeof ToolPermissionResolved>;
};
invocation?: z.infer<typeof ToolInvocationRequested>;
progress: Array<z.infer<typeof ToolProgress>>;
result?: z.infer<typeof ToolResult>;
}
export interface TurnState {
definition: z.infer<typeof TurnCreated>;
modelCalls: ModelCallState[];
toolCalls: ToolCallState[];
suspension?: z.infer<typeof TurnSuspended>;
terminal?:
| z.infer<typeof TurnCompleted>
| z.infer<typeof TurnFailed>
| z.infer<typeof TurnCancelled>;
usage: z.infer<typeof TurnUsage>;
}
function fail(message: string): never {
throw new TurnCorruptionError(message);
}
function isContextRef(
context: z.infer<typeof TurnContext>,
): context is z.infer<typeof TurnContextRef> {
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<typeof TurnUsage>,
usage: z.infer<typeof TurnUsage>,
): 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<typeof ModelCallRequested>,
): 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<typeof ModelStepEvent>,
): 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<typeof ModelCallCompleted>,
): 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<typeof ModelCallFailed>,
): 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<typeof ToolPermissionRequired>,
): 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<typeof ToolPermissionClassified>,
): 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<typeof ToolPermissionClassificationFailed>,
): 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<typeof ToolPermissionResolved>,
): 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<typeof ToolInvocationRequested>,
): 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<typeof ToolProgress>,
): 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<typeof ToolResult>,
): 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<typeof TurnSuspended>,
): 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<typeof TurnCompleted>,
): 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<z.infer<typeof TurnEvent>>,
): 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<typeof ToolResult>): 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<z.infer<typeof ConversationMessage>> {
const messages: Array<z.infer<typeof ConversationMessage>> = [
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;
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
}

View file

@ -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,
},
});

4
apps/x/pnpm-lock.yaml generated
View file

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