feat(x): agent snapshot inheritance, session inspection, runtime docs

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 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-02 13:17:30 +05:30
parent 97169be93e
commit 32e02d588c
14 changed files with 759 additions and 77 deletions

View file

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

View file

@ -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",

View file

@ -187,6 +187,9 @@ class FakeTurnRuntime implements ITurnRuntime {
logs = new Map<string, TEvent[]>();
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<string> {
@ -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();

View file

@ -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<z.infer<typeof ConversationMessage>>,
// The materialized agent snapshot (contextResolver.resolveAgent output —
// inherited snapshots must be resolved before composing).
agent: z.infer<typeof ResolvedAgent>,
encode: (messages: Array<z.infer<typeof ConversationMessage>>) => 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<z.infer<typeof ConversationMessage>>) => JsonValue[],
): Promise<ComposedModelRequest> {
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);
}

View file

@ -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).

View file

@ -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<typeof TurnContext>,
): Promise<Array<z.infer<typeof ConversationMessage>>>;
// 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<typeof ResolvedAgentSnapshot>,
): Promise<z.infer<typeof ResolvedAgent>>;
}
export class TurnRepoContextResolver implements IContextResolver {
@ -52,4 +61,28 @@ export class TurnRepoContextResolver implements IContextResolver {
segments.reverse();
return segments.flat();
}
async resolveAgent(
resolved: z.infer<typeof ResolvedAgentSnapshot>,
): Promise<z.infer<typeof ResolvedAgent>> {
if (!isInheritedSnapshot(resolved)) {
return resolved;
}
const visited = new Set<string>();
let current: z.infer<typeof ResolvedAgentSnapshot> = 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 };
}
}

View file

@ -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 -- <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]
// npm run inspect -- <turnId | sessionId | path/to/*.jsonl> [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<typeof convertFromMessages>[0]) =>
convertFromMessages(messages) as unknown as JsonValue[];
function usage(): never {
console.error(
"usage: inspect-turn <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]",
"usage: inspect <turnId | sessionId | path/to/*.jsonl> [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<void> {
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<z.infer<typeof TurnEvent>>;
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<typeof TurnEvent>);
} else {
events = await repo.read(turnId);
}
async function inspectTurn(
turnId: string,
events: Array<z.infer<typeof TurnEvent>>,
onlyIndex: number | undefined,
full: boolean,
): Promise<void> {
const state = reduceTurn(events);
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
const prefix = await resolver.resolve(state.definition.context);
const encode = (messages: Parameters<typeof convertFromMessages>[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<void> {
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<void> {
}
}
async function inspectSession(
sessionId: string,
full: boolean,
cascade: boolean,
): Promise<void> {
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 -- <turnId>; whole session: --turns)`);
}
}
async function main(): Promise<void> {
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<typeof TurnEvent>);
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);

View file

@ -146,6 +146,7 @@ function hangUntilAbort(onStarted?: () => void): ScriptedCall {
class FakeModelRegistry implements IModelRegistry {
requests: ModelStreamRequest[] = [];
resolved: Array<ResolvedModel["descriptor"]> = [];
private next = 0;
constructor(private readonly calls: ScriptedCall[]) {}
@ -153,6 +154,7 @@ class FakeModelRegistry implements IModelRegistry {
async resolve(
descriptor: ResolvedModel["descriptor"],
): Promise<ResolvedModel> {
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<string> {
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<typeof ResolvedAgent>;
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({

View file

@ -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<string> {
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<typeof ResolvedAgentSnapshot> = 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<string, RuntimeTool>();
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<TurnStreamEvent, TurnOutcome>;
private readonly resolvedContext: Array<z.infer<typeof ConversationMessage>>;
private readonly resolvedAgent: z.infer<typeof ResolvedAgent>;
private readonly model: ResolvedModel;
private readonly toolsByName: Map<string, RuntimeTool>;
private readonly signal: AbortSignal;
@ -270,6 +304,7 @@ class TurnAdvance {
state: TurnState;
stream: HotStream<TurnStreamEvent, TurnOutcome>;
resolvedContext: Array<z.infer<typeof ConversationMessage>>;
resolvedAgent: z.infer<typeof ResolvedAgent>;
model: ResolvedModel;
toolsByName: Map<string, RuntimeTool>;
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),
);

View file

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

View file

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

View file

@ -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<typeof ResolvedAgentSnapshot>,
): resolved is z.infer<typeof InheritedAgentSnapshot> {
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: [],