mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
1825202e65
commit
d821c419fe
8 changed files with 2711 additions and 2 deletions
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
201
apps/x/packages/shared/src/sessions.test.ts
Normal file
201
apps/x/packages/shared/src/sessions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
186
apps/x/packages/shared/src/sessions.ts
Normal file
186
apps/x/packages/shared/src/sessions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1337
apps/x/packages/shared/src/turns.test.ts
Normal file
1337
apps/x/packages/shared/src/turns.test.ts
Normal file
File diff suppressed because it is too large
Load diff
958
apps/x/packages/shared/src/turns.ts
Normal file
958
apps/x/packages/shared/src/turns.ts
Normal 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;
|
||||
}
|
||||
7
apps/x/packages/shared/tsconfig.build.json
Normal file
7
apps/x/packages/shared/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
11
apps/x/packages/shared/vitest.config.ts
Normal file
11
apps/x/packages/shared/vitest.config.ts
Normal 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
4
apps/x/pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue