From 32e02d588c95a852f98fb7810d922879aeeab86c Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:17:30 +0530 Subject: [PATCH] feat(x): agent snapshot inheritance, session inspection, runtime docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third application of the reference mechanism: session turns whose system prompt + tools are byte-identical to the context predecessor's materialized snapshot persist { agentId, model, inheritedFrom } instead of ~70KB per turn (measured: turn 2 of a session is now ~1.1KB total). Inheritance is decided at createTurn by equality against the materialized predecessor; the model stays concrete, and on materialization the inherited record's own agentId/model win — a rule the new test matrix caught as a real bug (the chain base's model was overriding a mid-session model switch). Reducer invariants: inherited snapshots must reference the context predecessor; tool identity arrives via invocation events. Test matrix per review: prompt-diff -> full snapshot, tools-diff -> full snapshot, model-switch -> inherits with concrete model, multi-hop chains materialize, standalone turns never inherit, unreadable predecessor falls back to full, cyclic inheritance is corruption, sessions denormalize the model from inherited snapshots. The inspector now handles sessions too (auto-detected): overview with per-turn status/size/input preview, --turns to cascade full turn inspection. Documented in a new repo-root AGENTS.md (storage layout, reference model, inspector usage, invariants) with a CLAUDE.md pointer. Breaking for dev turn files: wipe ~/.rowboat/storage. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 74 +++++++ CLAUDE.md | 1 + .../packages/core/docs/turn-runtime-design.md | 29 +++ apps/x/packages/core/package.json | 3 +- .../core/src/sessions/sessions.test.ts | 45 +++- .../core/src/turns/compose-model-request.ts | 13 +- .../core/src/turns/context-resolver.test.ts | 76 +++++++ .../core/src/turns/context-resolver.ts | 33 +++ apps/x/packages/core/src/turns/inspect-cli.ts | 174 +++++++++++---- .../x/packages/core/src/turns/runtime.test.ts | 199 +++++++++++++++++- apps/x/packages/core/src/turns/runtime.ts | 45 +++- apps/x/packages/shared/package.json | 36 ++-- apps/x/packages/shared/src/turns.test.ts | 61 ++++++ apps/x/packages/shared/src/turns.ts | 47 ++++- 14 files changed, 759 insertions(+), 77 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9a9b0a73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md — Working on the Rowboat runtime + +Context for AI coding agents (and humans) working on this repo. General +codebase orientation lives in `CLAUDE.md`; this file covers the **new +turn/session runtime** in `apps/x` — its storage, its debugging tools, and +the invariants you must not break. + +## The runtime in one paragraph + +Chats are **sessions**; each user message starts a **turn**. Both are +append-only JSONL event logs under `~/.rowboat/storage/{turns,sessions}/YYYY/MM/DD/`. +All state is derived by pure reducers (`reduceTurn`, `reduceSession` in +`@x/shared/src/turns.ts` / `sessions.ts`) shared byte-for-byte between the +main process and the renderer. Design specs: +`apps/x/packages/core/docs/turn-runtime-design.md` and `session-design.md` — +read the relevant spec before changing runtime behavior. + +## Storage is reference-based — files store each fact once + +Three applications of the same mechanism: + +1. **Context**: a session turn's `context` is `{ previousTurnId }`; the + conversation prefix is materialized by walking the chain + (`TurnRepoContextResolver`). +2. **Model requests**: `model_call_requested.request.messages` is a list of + string refs into the turn's own events — `"context"`, `"input"`, + `"assistant:"`, `"toolResult:"` — recording only what + is NEW since the previous call. +3. **Agent snapshots**: when a turn's system prompt + tools are + byte-identical to its predecessor's, `turn_created.agent.resolved` is + `{ agentId, model, inheritedFrom }` instead of re-persisting ~70KB. The + model stays concrete (mid-session model switches still inherit). + +The exact provider payload is rebuilt by `composeModelRequest` +(`packages/core/src/turns/compose-model-request.ts`) — **the same code path +the loop transmits through**, so the file plus the composer reproduce the +wire bytes exactly (there is a property test asserting composed == sent). + +## Inspecting turns and sessions + +```bash +cd apps/x/packages/core + +# A whole session: title, per-turn status/size/input preview +npm run inspect -- + +# One turn: per model call, the EXACT provider payload — resolved system +# prompt, tool list, wire-form messages (user-message context woven in, +# tool-result envelopes), and the response/failure +npm run inspect -- [modelCallIndex] [--full] + +# Cascade full turn inspection across a session +npm run inspect -- --turns +``` + +`--full` prints untruncated system prompts and message contents. Turn vs +session ids are auto-detected. This is the intended way to see "what did the +model actually receive" — the raw JSONL deliberately stores structural facts +and references, never the derived wire form. + +## Invariants to respect + +- Turn/session files are **append-only**; reducers reject impossible + histories loudly (`TurnCorruptionError`). Never hand-edit files. +- Durable events are persisted **before** side effects (model calls, tool + invocations). Deltas (`text_delta`, …) are stream-only, never persisted. +- The reducers in `@x/shared` must stay pure (no I/O, no node imports) — + the renderer imports them directly. +- Every behavior change needs tests: reducers in `packages/shared`, + runtime/sessions in `packages/core`, renderer stores/views in + `apps/renderer` (all vitest; run `npm test` per package). +- Schema changes: the schema is pre-release (`schemaVersion: 1` throughout); + breaking changes are acceptable but require wiping `~/.rowboat/storage` + and a note in the commit message. diff --git a/CLAUDE.md b/CLAUDE.md index 75a0f6a5..bf34bd90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,7 @@ Long-form docs for specific features. Read the relevant file before making chang |---------|-----| | Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` | | Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` | +| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` | ## Common Tasks diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index 88290b7e..091168f2 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -390,6 +390,35 @@ Rules: It rejects the execution and does not append `turn_failed`. - The reducer treats `context` as opaque data and never resolves references. +### 6.7 Agent snapshot inheritance + +Session turns whose resolved system prompt and tool set are byte-identical +to the context predecessor's materialized snapshot persist an inherited +form instead of repeating ~tens of KB per turn: + +```ts +type ResolvedAgentSnapshot = + | ResolvedAgent + | { agentId: string; model: ModelDescriptor; inheritedFrom: string }; +``` + +Rules: + +- Inheritance is decided at `createTurn` by comparing against the + predecessor's materialized snapshot; any difference in system prompt or + tools persists the full snapshot. An unreadable predecessor falls back to + the full snapshot. +- The model stays concrete: it is small, the session index denormalizes it, + and a mid-session model switch must not block inheritance. On + materialization the inherited record's own `agentId`/`model` win; only the + heavy fields come from the chain base. +- `inheritedFrom` must equal the turn's `context.previousTurnId` (reducer + invariant); materialization walks the chain with cycle detection + (`IContextResolver.resolveAgent`), exactly like context references. +- The reducer treats inherited snapshots as opaque: tool identity for + extraction arrives via `tool_invocation_requested` events instead of the + descriptor lookup. + ## 7. Turn creation schema The authoritative initial event is: diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 8ba46048..18c32a8c 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -9,7 +9,8 @@ "dev": "tsc -w -p tsconfig.build.json", "test": "vitest run", "test:watch": "vitest", - "inspect-turn": "node dist/turns/inspect-cli.js" + "inspect-turn": "node dist/turns/inspect-cli.js", + "inspect": "node dist/turns/inspect-cli.js" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "^0.39.0", diff --git a/apps/x/packages/core/src/sessions/sessions.test.ts b/apps/x/packages/core/src/sessions/sessions.test.ts index 6ff498d8..d4efdaa1 100644 --- a/apps/x/packages/core/src/sessions/sessions.test.ts +++ b/apps/x/packages/core/src/sessions/sessions.test.ts @@ -187,6 +187,9 @@ class FakeTurnRuntime implements ITurnRuntime { logs = new Map(); createError?: Error; script?: AdvanceScript; + // When set, session turns get an inherited agent snapshot (like the real + // runtime does for identical predecessors). + inheritSnapshots = false; private n = 0; async createTurn(input: CreateTurnInput): Promise { @@ -196,7 +199,22 @@ class FakeTurnRuntime implements ITurnRuntime { this.createTurnInputs.push(input); this.n += 1; const turnId = `2026-07-02T10-00-00Z-${String(this.n).padStart(7, "0")}-000`; - this.logs.set(turnId, [createdEvent(turnId, input)]); + const created = createdEvent(turnId, input); + if ( + this.inheritSnapshots && + created.type === "turn_created" && + !Array.isArray(input.context) + ) { + created.agent = { + ...created.agent, + resolved: { + agentId: FIXTURE_AGENT.agentId, + model: FIXTURE_MODEL, + inheritedFrom: input.context.previousTurnId, + }, + }; + } + this.logs.set(turnId, [created]); return turnId; } @@ -430,6 +448,31 @@ describe("sendMessage (13.3)", () => { } }); + it("denormalizes the model correctly for inherited agent snapshots", async () => { + const { sessions, repo, fake } = makeSessions(); + fake.inheritSnapshots = true; + const sessionId = await sessions.createSession(); + const first = await sessions.sendMessage(sessionId, user("one"), { + agent: { agentId: "copilot" }, + }); + fake.setLog(first.turnId, turnLog(first.turnId, sessionId, "completed")); + + const second = await sessions.sendMessage(sessionId, user("two"), { + agent: { agentId: "copilot" }, + }); + // The second turn's created event carries an inherited snapshot; the + // session still denormalizes the concrete model onto turn_appended, + // and status derivation reduces the inherited log fine. + const created = fake.logs.get(second.turnId)?.[0]; + expect( + created?.type === "turn_created" && "inheritedFrom" in created.agent.resolved, + ).toBe(true); + const events = await (repo as InMemorySessionRepo).read(sessionId); + const appended = events.filter((e) => e.type === "turn_appended"); + expect(appended[1]).toMatchObject({ model: FIXTURE_MODEL }); + expect(sessions.listSessions()[0].lastModel).toEqual(FIXTURE_MODEL); + }); + it("serializes concurrent sends: exactly one wins", async () => { const { sessions, repo } = makeSessions(); const sessionId = await sessions.createSession(); diff --git a/apps/x/packages/core/src/turns/compose-model-request.ts b/apps/x/packages/core/src/turns/compose-model-request.ts index ec0a7ef7..9811e9c0 100644 --- a/apps/x/packages/core/src/turns/compose-model-request.ts +++ b/apps/x/packages/core/src/turns/compose-model-request.ts @@ -2,6 +2,7 @@ import type { z } from "zod"; import { type ConversationMessage, type JsonValue, + type ResolvedAgent, type ToolDescriptor, type TurnState, requestMessagesFor, @@ -29,6 +30,9 @@ export function composeModelRequest( // The materialized cross-turn prefix (contextResolver output). Ignored // for inline-context turns, whose context rides the {context} ref. resolvedPrefix: Array>, + // The materialized agent snapshot (contextResolver.resolveAgent output — + // inherited snapshots must be resolved before composing). + agent: z.infer, encode: (messages: Array>) => JsonValue[], ): ComposedModelRequest { const call = state.modelCalls[modelCallIndex]; @@ -41,9 +45,9 @@ export function composeModelRequest( structural.push(...requestMessagesFor(state, index)); } return { - systemPrompt: state.definition.agent.resolved.systemPrompt, + systemPrompt: agent.systemPrompt, messages: encode(structural), - tools: state.definition.agent.resolved.tools, + tools: agent.tools, parameters: call.request.parameters, }; } @@ -57,5 +61,8 @@ export async function materializeModelRequest( encode: (messages: Array>) => JsonValue[], ): Promise { const prefix = await contextResolver.resolve(state.definition.context); - return composeModelRequest(state, modelCallIndex, prefix, encode); + const agent = await contextResolver.resolveAgent( + state.definition.agent.resolved, + ); + return composeModelRequest(state, modelCallIndex, prefix, agent, encode); } diff --git a/apps/x/packages/core/src/turns/context-resolver.test.ts b/apps/x/packages/core/src/turns/context-resolver.test.ts index 6ccfe671..35621178 100644 --- a/apps/x/packages/core/src/turns/context-resolver.test.ts +++ b/apps/x/packages/core/src/turns/context-resolver.test.ts @@ -241,6 +241,82 @@ describe("TurnRepoContextResolver", () => { ).rejects.toThrowError(/turn not found/); }); + it("resolveAgent materializes inherited snapshots through the chain", async () => { + const repo = new InMemoryTurnRepo(); + repo.seed(completedTurnLog(T1, [], "q1", "a1")); // concrete base + const inheritedLog = completedTurnLog(T2, { previousTurnId: T1 }, "q2", "a2").map( + (event) => + event.type === "turn_created" + ? { + ...event, + agent: { + ...event.agent, + resolved: { + agentId: "copilot", + model: { provider: "fake", model: "m" }, + inheritedFrom: T1, + }, + }, + } + : event, + ); + repo.seed(inheritedLog); + const resolver = new TurnRepoContextResolver({ turnRepo: repo }); + + const concrete = await resolver.resolveAgent({ + agentId: "copilot", + systemPrompt: "SYS", + model: { provider: "fake", model: "m" }, + tools: [], + }); + expect(concrete.systemPrompt).toBe("SYS"); // passthrough + + const materialized = await resolver.resolveAgent({ + agentId: "copilot", + model: { provider: "fake", model: "m2" }, + inheritedFrom: T2, // hops T2 -> T1 + }); + expect(materialized.systemPrompt).toBe("SYS"); + expect(materialized.tools).toEqual([]); + // The inherited record's own model wins over the chain base's model. + expect(materialized.model).toEqual({ provider: "fake", model: "m2" }); + }); + + it("resolveAgent rejects cyclic inheritance as corruption", async () => { + const repo = new InMemoryTurnRepo(); + const cyclic = completedTurnLog(T1, [], "q1", "a1").map((event) => + event.type === "turn_created" + ? { + ...event, + context: { previousTurnId: T1 }, + agent: { + ...event.agent, + resolved: { + agentId: "copilot", + model: { provider: "fake", model: "m" }, + inheritedFrom: T1, + }, + }, + } + : event, + ); + // Fix the request contextRef to match the now-ref context. + const fixed = cyclic.map((event) => + event.type === "model_call_requested" + ? { ...event, request: { ...event.request, contextRef: { previousTurnId: T1 } } } + : event, + ); + repo.seed(fixed); + const resolver = new TurnRepoContextResolver({ turnRepo: repo }); + await expect( + resolver.resolveAgent({ + agentId: "copilot", + model: { provider: "fake", model: "m" }, + inheritedFrom: T1, + }), + ).rejects.toThrowError(TurnCorruptionError); + }); + it("rejects cyclic reference chains as corruption", async () => { const repo = new InMemoryTurnRepo(); // Two turns referencing each other (only constructable by corruption). diff --git a/apps/x/packages/core/src/turns/context-resolver.ts b/apps/x/packages/core/src/turns/context-resolver.ts index 0dce3181..3785cc26 100644 --- a/apps/x/packages/core/src/turns/context-resolver.ts +++ b/apps/x/packages/core/src/turns/context-resolver.ts @@ -1,8 +1,11 @@ import type { z } from "zod"; import { type ConversationMessage, + type ResolvedAgent, + type ResolvedAgentSnapshot, type TurnContext, TurnCorruptionError, + isInheritedSnapshot, reduceTurn, turnTranscript, } from "@x/shared/dist/turns.js"; @@ -17,6 +20,12 @@ export interface IContextResolver { resolve( context: z.infer, ): Promise>>; + // Materializes an inherited agent snapshot by walking inheritedFrom to + // the nearest concrete snapshot (same discipline as context references: + // deterministic, from durable state, cycle-checked). + resolveAgent( + resolved: z.infer, + ): Promise>; } export class TurnRepoContextResolver implements IContextResolver { @@ -52,4 +61,28 @@ export class TurnRepoContextResolver implements IContextResolver { segments.reverse(); return segments.flat(); } + + async resolveAgent( + resolved: z.infer, + ): Promise> { + if (!isInheritedSnapshot(resolved)) { + return resolved; + } + const visited = new Set(); + let current: z.infer = resolved; + while (isInheritedSnapshot(current)) { + const turnId = current.inheritedFrom; + if (visited.has(turnId)) { + throw new TurnCorruptionError( + `cyclic agent snapshot inheritance at turn ${turnId}`, + ); + } + visited.add(turnId); + const events = await this.turnRepo.read(turnId); + current = reduceTurn(events).definition.agent.resolved; + } + // Only the heavy fields inherit; the turn's own concrete identity + // (agentId, model — a mid-session model switch) always wins. + return { ...current, agentId: resolved.agentId, model: resolved.model }; + } } diff --git a/apps/x/packages/core/src/turns/inspect-cli.ts b/apps/x/packages/core/src/turns/inspect-cli.ts index 3329d99d..b662e407 100644 --- a/apps/x/packages/core/src/turns/inspect-cli.ts +++ b/apps/x/packages/core/src/turns/inspect-cli.ts @@ -1,31 +1,48 @@ -// Turn inspector: prints, per model call, the EXACT provider payload the -// loop sent — rebuilt from the durable file by the same composer the loop -// transmits through (compose-model-request.ts). This is where the woven -// wire-form messages (user-message context, attachments, tool-result -// envelopes) are visible; the file itself stores only structural facts and -// references. +// Runtime storage inspector. Works on turns AND sessions (auto-detected): // -// Usage: -// npm run inspect-turn -- [modelCallIndex] [--full] +// npm run inspect -- [modelCallIndex] [--full] [--turns] // -// --full prints the entire system prompt and untruncated message contents. +// Turn mode prints, per model call, the EXACT provider payload the loop sent +// — rebuilt from the durable file by the same composer the loop transmits +// through (compose-model-request.ts). This is where the woven wire-form +// messages (user-message context, attachments, tool-result envelopes) are +// visible; the file itself stores only structural facts and references. +// +// Session mode prints the session overview (title, turns, statuses, sizes); +// pass --turns to cascade full turn inspection for every turn. +// +// --full prints untruncated system prompts and message contents. import fs from "node:fs"; import path from "node:path"; import { + deriveTurnStatus, reduceTurn, type JsonValue, type TurnEvent, + type TurnState, } from "@x/shared/dist/turns.js"; +import { reduceSession } from "@x/shared/dist/sessions.js"; import type { z } from "zod"; import { convertFromMessages } from "../agents/runtime.js"; import { WorkDir } from "../config/config.js"; +import { FSSessionRepo } from "../sessions/fs-repo.js"; import { composeModelRequest } from "./compose-model-request.js"; import { TurnRepoContextResolver } from "./context-resolver.js"; import { FSTurnRepo } from "./fs-repo.js"; +const turnRepo = new FSTurnRepo({ + turnsRootDir: path.join(WorkDir, "storage", "turns"), +}); +const sessionRepo = new FSSessionRepo({ + sessionsRootDir: path.join(WorkDir, "storage", "sessions"), +}); +const resolver = new TurnRepoContextResolver({ turnRepo }); +const encode = (messages: Parameters[0]) => + convertFromMessages(messages) as unknown as JsonValue[]; + function usage(): never { console.error( - "usage: inspect-turn [modelCallIndex] [--full]", + "usage: inspect [modelCallIndex] [--full] [--turns]", ); process.exit(1); } @@ -35,46 +52,43 @@ function clip(text: string, full: boolean, limit = 400): string { return `${text.slice(0, limit)}… [${text.length} chars total; pass --full]`; } -async function main(): Promise { - const args = process.argv.slice(2).filter((a) => a !== "--full"); - const full = process.argv.includes("--full"); - const target = args[0]; - if (!target) usage(); - const onlyIndex = args[1] !== undefined ? Number(args[1]) : undefined; +function inputPreview(state: TurnState): string { + const content = state.definition.input.content; + const text = + typeof content === "string" + ? content + : content + .map((part) => (part.type === "text" ? part.text : `<${part.type}>`)) + .join(" "); + return text.replace(/\s+/g, " ").slice(0, 60); +} - const turnsRootDir = path.join(WorkDir, "storage", "turns"); - const repo = new FSTurnRepo({ turnsRootDir }); - const turnId = target.endsWith(".jsonl") - ? path.basename(target, ".jsonl") - : target; - - let events: Array>; - if (target.endsWith(".jsonl") && fs.existsSync(target)) { - // Direct path: parse in place (still strict via the reducer below). - events = fs - .readFileSync(target, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as z.infer); - } else { - events = await repo.read(turnId); - } +async function inspectTurn( + turnId: string, + events: Array>, + onlyIndex: number | undefined, + full: boolean, +): Promise { const state = reduceTurn(events); - const resolver = new TurnRepoContextResolver({ turnRepo: repo }); const prefix = await resolver.resolve(state.definition.context); - const encode = (messages: Parameters[0]) => - convertFromMessages(messages) as unknown as JsonValue[]; + const agent = await resolver.resolveAgent(state.definition.agent.resolved); - console.log(`turn ${turnId}`); + const inherited = + "inheritedFrom" in state.definition.agent.resolved + ? ` (snapshot inherited from ${state.definition.agent.resolved.inheritedFrom})` + : ""; + console.log(`turn ${turnId} status ${deriveTurnStatus(state)}`); console.log( - `agent ${state.definition.agent.resolved.agentId} model ${state.definition.agent.resolved.model.provider}/${state.definition.agent.resolved.model.model} calls ${state.modelCalls.length}`, + `agent ${agent.agentId} model ${agent.model.provider}/${agent.model.model} calls ${state.modelCalls.length}${inherited}`, ); for (const call of state.modelCalls) { if (onlyIndex !== undefined && call.index !== onlyIndex) continue; - const composed = composeModelRequest(state, call.index, prefix, encode); + const composed = composeModelRequest(state, call.index, prefix, agent, encode); console.log(`\n━━ model call ${call.index} ━━ (as sent to the provider)`); - console.log(`system (${composed.systemPrompt.length} chars): ${clip(composed.systemPrompt, full)}`); + console.log( + `system (${composed.systemPrompt.length} chars): ${clip(composed.systemPrompt, full)}`, + ); console.log( `tools (${composed.tools.length}): ${composed.tools.map((t) => t.name).join(", ")}`, ); @@ -82,9 +96,7 @@ async function main(): Promise { for (const message of composed.messages) { const m = message as { role?: string; content?: unknown }; const content = - typeof m.content === "string" - ? m.content - : JSON.stringify(m.content); + typeof m.content === "string" ? m.content : JSON.stringify(m.content); console.log(` [${m.role}] ${clip(content, full)}`); } if (call.error !== undefined) { @@ -99,6 +111,82 @@ async function main(): Promise { } } +async function inspectSession( + sessionId: string, + full: boolean, + cascade: boolean, +): Promise { + const state = reduceSession(await sessionRepo.read(sessionId)); + console.log(`session ${sessionId}`); + console.log( + `title "${state.title ?? ""}" turns ${state.turns.length} created ${state.createdAt} updated ${state.updatedAt}`, + ); + for (const ref of state.turns) { + try { + const events = await turnRepo.read(ref.turnId); + const turnState = reduceTurn(events); + const bytes = JSON.stringify(events).length; + console.log( + ` ${String(ref.sessionSeq).padStart(3)}. ${ref.turnId} ${deriveTurnStatus(turnState).padEnd(9)} ${turnState.modelCalls.length} calls ~${Math.round(bytes / 1024)}KB "${inputPreview(turnState)}"`, + ); + } catch (error) { + console.log( + ` ${String(ref.sessionSeq).padStart(3)}. ${ref.turnId} UNREADABLE: ${error instanceof Error ? error.message : error}`, + ); + } + } + if (cascade) { + for (const ref of state.turns) { + console.log(`\n════════ turn ${ref.sessionSeq} ════════`); + try { + await inspectTurn(ref.turnId, await turnRepo.read(ref.turnId), undefined, full); + } catch (error) { + console.log(`unreadable: ${error instanceof Error ? error.message : error}`); + } + } + } else { + console.log(`\n(inspect a turn: npm run inspect -- ; whole session: --turns)`); + } +} + +async function main(): Promise { + const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith("--"))); + const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); + const full = flags.has("--full"); + const cascade = flags.has("--turns"); + const target = args[0]; + if (!target) usage(); + const onlyIndex = args[1] !== undefined ? Number(args[1]) : undefined; + + const id = target.endsWith(".jsonl") ? path.basename(target, ".jsonl") : target; + + // Direct file path: session files live under storage/sessions. + if (target.endsWith(".jsonl") && fs.existsSync(target)) { + if (path.resolve(target).includes(`${path.sep}sessions${path.sep}`)) { + await inspectSession(id, full, cascade); + return; + } + const events = fs + .readFileSync(target, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as z.infer); + await inspectTurn(id, events, onlyIndex, full); + return; + } + + // Bare id: try turn first, then session. + try { + const events = await turnRepo.read(id); + await inspectTurn(id, events, onlyIndex, full); + } catch (error) { + if (!(error instanceof Error) || !/turn not found/.test(error.message)) { + throw error; + } + await inspectSession(id, full, cascade); + } +} + main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exit(1); diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index 1d2671ed..170ba61b 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -146,6 +146,7 @@ function hangUntilAbort(onStarted?: () => void): ScriptedCall { class FakeModelRegistry implements IModelRegistry { requests: ModelStreamRequest[] = []; + resolved: Array = []; private next = 0; constructor(private readonly calls: ScriptedCall[]) {} @@ -153,6 +154,7 @@ class FakeModelRegistry implements IModelRegistry { async resolve( descriptor: ResolvedModel["descriptor"], ): Promise { + this.resolved.push(descriptor); return { descriptor, // Identity encoding: tests assert on structural messages directly. @@ -275,7 +277,10 @@ class FakeBus { } class FakeIdGen { - private n = 0; + private n: number; + constructor(start = 0) { + this.n = start; + } async next(): Promise { this.n += 1; return `2026-07-02T10-00-00Z-${String(this.n).padStart(7, "0")}-000`; @@ -296,6 +301,7 @@ function makeRuntime(opts: { agent?: z.infer; agentError?: string; repo?: InMemoryTurnRepo; + idStart?: number; } = {}) { const repo = opts.repo ?? new InMemoryTurnRepo(); const models = new FakeModelRegistry(opts.models ?? []); @@ -304,7 +310,7 @@ function makeRuntime(opts: { const bus = new FakeBus(); const runtime = new TurnRuntime({ turnRepo: repo, - idGenerator: new FakeIdGen(), + idGenerator: new FakeIdGen(opts.idStart ?? 0), clock: new FakeClock(), agentResolver: new FakeAgentResolver(opts.agent ?? defaultAgent, opts.agentError), modelRegistry: models, @@ -540,7 +546,7 @@ describe("mixed sync and async tools (26.2)", () => { // reproduces exactly what the model received. const state = reduceTurn(log); for (const index of [0, 1]) { - const composed = composeModelRequest(state, index, [], (m) => m as never); + const composed = composeModelRequest(state, index, [], defaultAgent, (m) => m as never); expect(composed.messages).toEqual(models.requests[index].messages); expect(composed.systemPrompt).toBe(models.requests[index].systemPrompt); expect(composed.tools).toEqual(models.requests[index].tools); @@ -1420,6 +1426,193 @@ describe("crash recovery (26.7)", () => { // §26.8 Historical and live reconstruction // --------------------------------------------------------------------------- +describe("agent snapshot inheritance", () => { + it("inherits system prompt + tools from an identical context predecessor", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantText("first"))), + respond(completedResp(assistantText("second"))), + ], + }); + const first = await newTurn(runtime, { sessionId: "S" }); + await advanceAndSettle(runtime, first); + + const second = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect(created.type === "turn_created" ? created.agent.resolved : null).toEqual({ + agentId: "copilot", + model: defaultAgent.model, + inheritedFrom: first, + }); + + // The inherited turn still sends the full materialized snapshot. + const { outcome } = await advanceAndSettle(runtime, second); + expect(outcome?.status).toBe("completed"); + expect(models.requests[1].systemPrompt).toBe("SYS"); + expect(models.requests[1].tools).toEqual(defaultAgent.tools); + }); + + it("a model switch still inherits the prompt and tools (model stays concrete)", async () => { + const repo = new InMemoryTurnRepo(); + const a = makeRuntime({ + repo, + models: [respond(completedResp(assistantText("first")))], + }); + const first = await newTurn(a.runtime, { sessionId: "S" }); + await advanceAndSettle(a.runtime, first); + + const switched = { + ...defaultAgent, + model: { provider: "anthropic", model: "claude-x" }, + }; + const b = makeRuntime({ + repo, + agent: switched, + idStart: 200, + models: [respond(completedResp(assistantText("second")))], + }); + const second = await b.runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect(created.type === "turn_created" ? created.agent.resolved : null).toEqual({ + agentId: "copilot", + model: { provider: "anthropic", model: "claude-x" }, + inheritedFrom: first, + }); + const { outcome } = await advanceAndSettle(b.runtime, second); + expect(outcome?.status).toBe("completed"); + // The switched model was resolved; prompt/tools came through the chain. + expect(b.models.resolved[0]).toEqual({ provider: "anthropic", model: "claude-x" }); + expect(b.models.requests[0].systemPrompt).toBe("SYS"); + expect(b.models.requests[0].tools).toEqual(defaultAgent.tools); + }); + + it("a tools difference forces a full snapshot", async () => { + const repo = new InMemoryTurnRepo(); + const a = makeRuntime({ + repo, + models: [respond(completedResp(assistantText("first")))], + }); + const first = await newTurn(a.runtime, { sessionId: "S" }); + await advanceAndSettle(a.runtime, first); + + const fewerTools = { ...defaultAgent, tools: [echoDescriptor] }; + const b = makeRuntime({ repo, agent: fewerTools, idStart: 300 }); + const second = await b.runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect( + created.type === "turn_created" && "tools" in created.agent.resolved + ? created.agent.resolved.tools + : null, + ).toEqual([echoDescriptor]); + }); + + it("inheritance chains across turns and materializes through multiple hops", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantText("one"))), + respond(completedResp(assistantText("two"))), + respond(completedResp(assistantText("three"))), + ], + }); + const t1 = await newTurn(runtime, { sessionId: "S" }); + await advanceAndSettle(runtime, t1); + const t2 = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: t1 }, + input: user("two"), + config: { humanAvailable: true }, + }); + await advanceAndSettle(runtime, t2); + const t3 = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: t2 }, + input: user("three"), + config: { humanAvailable: true }, + }); + const created3 = (await persisted(repo, t3))[0]; + // t3 inherits from t2, which itself inherits from t1 (the concrete base). + expect( + created3.type === "turn_created" && "inheritedFrom" in created3.agent.resolved + ? created3.agent.resolved.inheritedFrom + : null, + ).toBe(t2); + const { outcome } = await advanceAndSettle(runtime, t3); + expect(outcome?.status).toBe("completed"); + expect(models.requests[2].systemPrompt).toBe("SYS"); + expect(models.requests[2].tools).toEqual(defaultAgent.tools); + }); + + it("standalone (inline-context) turns always persist a full snapshot", async () => { + const { runtime, repo } = makeRuntime(); + const turnId = await newTurn(runtime); + const created = (await persisted(repo, turnId))[0]; + expect( + created.type === "turn_created" && "systemPrompt" in created.agent.resolved, + ).toBe(true); + }); + + it("falls back to a full snapshot when the predecessor is unreadable", async () => { + const { runtime, repo } = makeRuntime(); + const turnId = await runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: "2026-07-02T09-00-00Z-0000404-000" }, + input: user("orphan ref"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, turnId))[0]; + expect( + created.type === "turn_created" && "systemPrompt" in created.agent.resolved, + ).toBe(true); + }); + + it("persists a full snapshot when the resolved agent differs", async () => { + const repo = new InMemoryTurnRepo(); + const a = makeRuntime({ + repo, + models: [respond(completedResp(assistantText("first")))], + }); + const first = await newTurn(a.runtime, { sessionId: "S" }); + await advanceAndSettle(a.runtime, first); + + const changedAgent = { ...defaultAgent, systemPrompt: "SYS v2" }; + const b = makeRuntime({ repo, agent: changedAgent, idStart: 100 }); + const second = await b.runtime.createTurn({ + agent: { agentId: "copilot" }, + sessionId: "S", + context: { previousTurnId: first }, + input: user("again"), + config: { humanAvailable: true }, + }); + const created = (await persisted(repo, second))[0]; + expect( + created.type === "turn_created" && "systemPrompt" in created.agent.resolved + ? created.agent.resolved.systemPrompt + : null, + ).toBe("SYS v2"); + }); +}); + describe("historical and live reconstruction (26.8)", () => { it("getTurn is read-only and matches live durable events", async () => { const { runtime, repo } = makeRuntime({ diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index d82e575a..80f657b9 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -6,6 +6,8 @@ import { type JsonValue, type ModelCallFailed, type ModelRequest, + type ResolvedAgent, + type ResolvedAgentSnapshot, assistantRef, toolResultRef, type ToolCallState, @@ -109,13 +111,42 @@ export class TurnRuntime implements ITurnRuntime { async createTurn(input: CreateTurnInput): Promise { const resolved = await this.agentResolver.resolve(input.agent); const turnId = await this.idGenerator.next(); + // Inherit the heavy snapshot fields (system prompt + tools) when they + // are byte-identical to the context predecessor's materialized + // snapshot — the same reference mechanism as context and requests. + // The model stays concrete for the session index and so a model + // switch never blocks inheritance. + let snapshot: z.infer = resolved; + if (!Array.isArray(input.context)) { + try { + const previousEvents = await this.turnRepo.read( + input.context.previousTurnId, + ); + const previous = await this.contextResolver.resolveAgent( + reduceTurn(previousEvents).definition.agent.resolved, + ); + if ( + previous.systemPrompt === resolved.systemPrompt && + JSON.stringify(previous.tools) === JSON.stringify(resolved.tools) + ) { + snapshot = { + agentId: resolved.agentId, + model: resolved.model, + inheritedFrom: input.context.previousTurnId, + }; + } + } catch { + // Unreadable predecessor: persist the full snapshot; context + // resolution surfaces the real problem at advance time. + } + } const event = TurnCreated.parse({ type: "turn_created", schemaVersion: 1, turnId, ts: this.clock.now(), sessionId: input.sessionId ?? null, - agent: { requested: input.agent, resolved }, + agent: { requested: input.agent, resolved: snapshot }, context: input.context, input: input.input, config: { @@ -188,11 +219,12 @@ export class TurnRuntime implements ITurnRuntime { const resolvedContext = await this.contextResolver.resolve( definition.context, ); - const model = await this.modelRegistry.resolve( - definition.agent.resolved.model, + const resolvedAgent = await this.contextResolver.resolveAgent( + definition.agent.resolved, ); + const model = await this.modelRegistry.resolve(resolvedAgent.model); const toolsByName = new Map(); - for (const descriptor of definition.agent.resolved.tools) { + for (const descriptor of resolvedAgent.tools) { const tool = await this.toolRegistry.resolve(descriptor); if ( tool.descriptor.toolId !== descriptor.toolId || @@ -223,6 +255,7 @@ export class TurnRuntime implements ITurnRuntime { state, stream, resolvedContext, + resolvedAgent, model, toolsByName, signal: controller.signal, @@ -250,6 +283,7 @@ class TurnAdvance { private state: TurnState; private readonly stream: HotStream; private readonly resolvedContext: Array>; + private readonly resolvedAgent: z.infer; private readonly model: ResolvedModel; private readonly toolsByName: Map; private readonly signal: AbortSignal; @@ -270,6 +304,7 @@ class TurnAdvance { state: TurnState; stream: HotStream; resolvedContext: Array>; + resolvedAgent: z.infer; model: ResolvedModel; toolsByName: Map; signal: AbortSignal; @@ -283,6 +318,7 @@ class TurnAdvance { this.state = init.state; this.stream = init.stream; this.resolvedContext = init.resolvedContext; + this.resolvedAgent = init.resolvedAgent; this.model = init.model; this.toolsByName = init.toolsByName; this.signal = init.signal; @@ -893,6 +929,7 @@ class TurnAdvance { this.state, index, this.resolvedContext, + this.resolvedAgent, (messages) => this.model.encodeMessages(messages), ); diff --git a/apps/x/packages/shared/package.json b/apps/x/packages/shared/package.json index 5a1a9c45..31f99cdd 100644 --- a/apps/x/packages/shared/package.json +++ b/apps/x/packages/shared/package.json @@ -1,19 +1,19 @@ { - "name": "@x/shared", - "private": true, - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "scripts": { - "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 + "name": "@x/shared", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "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:" + } +} diff --git a/apps/x/packages/shared/src/turns.test.ts b/apps/x/packages/shared/src/turns.test.ts index 844d76b2..84a5a9c7 100644 --- a/apps/x/packages/shared/src/turns.test.ts +++ b/apps/x/packages/shared/src/turns.test.ts @@ -627,6 +627,67 @@ describe("context references", () => { }); }); +describe("inherited agent snapshots", () => { + const inherited = { + agentId: "copilot", + model: { provider: "openai", model: "gpt-test" }, + inheritedFrom: PREV_TURN_ID, + }; + + it("accepts inheritance referencing the context predecessor; identity arrives via invocation", () => { + const call0 = assistantCalls(toolCallPart("tc1", "echo")); + const state = reduceTurn([ + created({ + context: { previousTurnId: PREV_TURN_ID }, + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID } }), + completed(0, call0), + ]); + // No descriptor lookup without the concrete snapshot… + expect(state.toolCalls[0].toolId).toBeUndefined(); + expect(state.toolCalls[0].execution).toBeUndefined(); + + // …until tool_invocation_requested supplies identity. + const withInvocation = reduceTurn([ + created({ + context: { previousTurnId: PREV_TURN_ID }, + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + requested(0, ["input"], { contextRef: { previousTurnId: PREV_TURN_ID } }), + completed(0, call0), + invocation("tc1"), + result("tc1", "echo"), + ]); + expect(withInvocation.toolCalls[0].toolId).toBe("tool.echo"); + expect(withInvocation.toolCalls[0].execution).toBe("sync"); + }); + + it("rejects an inherited snapshot on an inline-context turn", () => { + expectCorruption( + [ + created({ + context: [], + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + ], + /inherited agent snapshot must reference the turn's context predecessor/, + ); + }); + + it("rejects an inherited snapshot pointing at a different turn than the context", () => { + expectCorruption( + [ + created({ + context: { previousTurnId: "some-other-turn" }, + agent: { requested: { agentId: "copilot" }, resolved: inherited }, + }), + ], + /inherited agent snapshot must reference the turn's context predecessor/, + ); + }); +}); + describe("suspension", () => { it("accepts a snapshot matching pending permissions and async tools", () => { const call0 = assistantCalls( diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 6d28e035..df9a65f4 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -58,6 +58,30 @@ export const ResolvedAgent = z.object({ tools: z.array(ToolDescriptor), }); +// Session turns whose system prompt and tool set are byte-identical to the +// previous turn's materialized snapshot inherit it by reference instead of +// re-persisting ~tens of KB per turn (same mechanism as context references). +// The model stays concrete: it is tiny, the session index denormalizes it, +// and a mid-session model switch must not block inheritance. Written only +// when equality holds at creation; materialization walks inheritedFrom to +// the nearest concrete snapshot. +export const InheritedAgentSnapshot = z.object({ + agentId: z.string(), + model: ModelDescriptor, + inheritedFrom: z.string(), +}); + +export const ResolvedAgentSnapshot = z.union([ + ResolvedAgent, + InheritedAgentSnapshot, +]); + +export function isInheritedSnapshot( + resolved: z.infer, +): resolved is z.infer { + return "inheritedFrom" in resolved; +} + // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- @@ -108,7 +132,7 @@ export const TurnCreated = z.object({ sessionId: z.string().nullable(), agent: z.object({ requested: RequestedAgent, - resolved: ResolvedAgent, + resolved: ResolvedAgentSnapshot, }), context: TurnContext, input: UserMessage, @@ -614,9 +638,12 @@ function applyModelCallCompleted( 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, - ); + const resolved = state.definition.agent.resolved; + // Inherited snapshots resolve outside the reducer; identity fields + // then arrive via tool_invocation_requested events. + const descriptor = isInheritedSnapshot(resolved) + ? undefined + : resolved.tools.find((tool) => tool.name === part.toolName); state.toolCalls.push({ modelCallIndex: event.modelCallIndex, order: order++, @@ -850,6 +877,18 @@ export function reduceTurn( fail(`unsupported turn schema version: ${String(first.schemaVersion)}`); } + if (isInheritedSnapshot(first.agent.resolved)) { + const context = first.context; + if ( + Array.isArray(context) || + context.previousTurnId !== first.agent.resolved.inheritedFrom + ) { + fail( + "inherited agent snapshot must reference the turn's context predecessor", + ); + } + } + const state: TurnState = { definition: first, modelCalls: [],