@yaag/runtime 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { YaagError } from "../errors.ts";
2
2
  import type {
3
3
  AskMarker,
4
4
  AskMarkerContext,
5
+ CompactionMarker,
5
6
  OpenOptions,
6
7
  RecordedSpawnSelection,
7
8
  } from "../transport/index.ts";
@@ -9,7 +10,13 @@ import type { CassetteAgent, CassetteAsk, CassetteSpawn } from "./cassette.ts";
9
10
 
10
11
  /** A pure description of the first strict replay identity mismatch. */
11
12
  export interface ReplayMismatch {
12
- readonly kind: "unexpected-spawn" | "spawn-options" | "changed-ask" | "extra-ask";
13
+ readonly kind:
14
+ | "unexpected-spawn"
15
+ | "spawn-options"
16
+ | "changed-ask"
17
+ | "extra-ask"
18
+ | "changed-compaction"
19
+ | "extra-compaction";
13
20
  readonly agent: string;
14
21
  readonly index?: number;
15
22
  readonly expectedHash: string;
@@ -71,6 +78,32 @@ export const replayMismatch = {
71
78
  }),
72
79
  };
73
80
  },
81
+
82
+ compaction(
83
+ agent: CassetteAgent,
84
+ cursor: number,
85
+ actual: CompactionMarker,
86
+ ): ReplayMismatch | null {
87
+ const expected = agent.compactions?.[cursor];
88
+ if (!expected) {
89
+ return {
90
+ kind: "extra-compaction",
91
+ agent: agent.spawn.name,
92
+ index: actual.index,
93
+ expectedHash: "<none>",
94
+ actualHash: actual.hash,
95
+ };
96
+ }
97
+ return expected.index === actual.index && expected.hash === actual.hash
98
+ ? null
99
+ : {
100
+ kind: "changed-compaction",
101
+ agent: agent.spawn.name,
102
+ index: actual.index,
103
+ expectedHash: expected.hash,
104
+ actualHash: actual.hash,
105
+ };
106
+ },
74
107
  };
75
108
 
76
109
  /** Where a user reads what a Divergence means and what to do about it. */
@@ -86,7 +119,8 @@ export function strictReplay(mismatch: ReplayMismatch): never {
86
119
  mismatch.agent,
87
120
  );
88
121
  }
89
- const at = mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`;
122
+ const at =
123
+ compactionAt(mismatch) ?? (mismatch.index === undefined ? "spawn" : `Ask ${mismatch.index}`);
90
124
  const changed =
91
125
  mismatch.kind === "spawn-options" && mismatch.changedFields !== undefined
92
126
  ? ` changed ${mismatch.changedFields.join(", ")};`
@@ -98,6 +132,14 @@ export function strictReplay(mismatch: ReplayMismatch): never {
98
132
  );
99
133
  }
100
134
 
135
+ /** Names the compaction seam of a compaction mismatch, or nothing for other kinds. */
136
+ function compactionAt(mismatch: ReplayMismatch): string | undefined {
137
+ if (mismatch.kind !== "changed-compaction" && mismatch.kind !== "extra-compaction") {
138
+ return undefined;
139
+ }
140
+ return `compaction #${mismatch.index}`;
141
+ }
142
+
101
143
  /** The identity fields a changed spawn altered, for the Divergence report (ADR-0039). */
102
144
  function spawnChangedFields(expected: CassetteSpawn, actual: OpenOptions): readonly string[] {
103
145
  return changedAmong(SPAWN_OPEN_FIELDS, expected, actual);
@@ -145,7 +187,15 @@ const SPAWN_IDENTITY_FIELDS = [
145
187
  ] as const;
146
188
 
147
189
  /** The spawn identity fields plus the Agent name, which only an open request carries. */
148
- const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS, "declaredExtensions"] as const;
190
+ const SPAWN_OPEN_FIELDS = [
191
+ "name",
192
+ ...SPAWN_IDENTITY_FIELDS,
193
+ "declaredExtensions",
194
+ "parent",
195
+ "origin",
196
+ "forkOf",
197
+ "forkAsks",
198
+ ] as const;
149
199
 
150
200
  /** The listed fields whose canonical JSON differs between two identity records. */
151
201
  function changedAmong<Key extends string>(
@@ -216,6 +266,11 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
216
266
  ...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
217
267
  ? {}
218
268
  : { declaredExtensions: options.declaredExtensions }),
269
+ ...(options.parent === undefined ? {} : { parent: options.parent }),
270
+ // Conditional, so a Cassette recorded before forking keeps its hash.
271
+ ...(options.origin === undefined ? {} : { origin: options.origin }),
272
+ ...(options.forkOf === undefined ? {} : { forkOf: options.forkOf }),
273
+ ...(options.forkAsks === undefined ? {} : { forkAsks: options.forkAsks }),
219
274
  }),
220
275
  );
221
276
  return hasher.digest("hex");
@@ -3,6 +3,8 @@ import type {
3
3
  AgentTransport,
4
4
  AskMarker,
5
5
  AskPlayback,
6
+ CompactionMarker,
7
+ CompactionPlayback,
6
8
  Frame,
7
9
  OpenOptions,
8
10
  RecordedSpawnSelection,
@@ -30,7 +32,14 @@ export function replayTransport(cassette: Cassette): TransportFactory {
30
32
  const mismatch = replayMismatch.spawn(agent, options);
31
33
  if (mismatch) strictReplay(mismatch);
32
34
  spawnCursor += 1;
33
- if (agent.worktree !== undefined) observeStartup?.({ worktree: agent.worktree });
35
+ // The recorded session file rides along, so a replayed Agent can be
36
+ // forked exactly where the recording forked it (ADR-0044).
37
+ if (agent.worktree !== undefined || agent.sessionFile !== undefined) {
38
+ observeStartup?.({
39
+ ...(agent.worktree === undefined ? {} : { worktree: agent.worktree }),
40
+ ...(agent.sessionFile === undefined ? {} : { sessionFile: agent.sessionFile }),
41
+ });
42
+ }
34
43
  return new ReplayTransport(new CassetteReplay(agent));
35
44
  },
36
45
 
@@ -46,6 +55,7 @@ class ReplayTransport implements AgentTransport {
46
55
  readonly model: string;
47
56
  readonly #replay: CassetteReplay;
48
57
  #askCursor = 0;
58
+ #compactionCursor = 0;
49
59
  #closed: Promise<AgentStats> | null = null;
50
60
 
51
61
  constructor(replay: CassetteReplay) {
@@ -72,6 +82,17 @@ class ReplayTransport implements AgentTransport {
72
82
  // A completed replay does not alter the recorded Cassette.
73
83
  }
74
84
 
85
+ beginCompaction(marker: CompactionMarker): CompactionPlayback {
86
+ const mismatch = replayMismatch.compaction(this.#replay.agent, this.#compactionCursor, marker);
87
+ if (mismatch) strictReplay(mismatch);
88
+ this.#compactionCursor += 1;
89
+ return this.#replay.beginCompaction(marker);
90
+ }
91
+
92
+ finishCompaction(): void {
93
+ // A completed replay does not alter the recorded Cassette.
94
+ }
95
+
75
96
  recordedExtractionPolicy(index: number): string | undefined {
76
97
  return this.#replay.agent.asks[index]?.extractionPolicy;
77
98
  }
@@ -4,6 +4,9 @@ import type {
4
4
  AskCompletion,
5
5
  AskMarker,
6
6
  AskPlayback,
7
+ CompactionMarker,
8
+ CompactionPlayback,
9
+ CompactionResult,
7
10
  Frame,
8
11
  OpenOptions,
9
12
  RecordedSpawnSelection,
@@ -64,6 +67,7 @@ class ResumeTransport implements AgentTransport {
64
67
  readonly #frames = new FrameQueue();
65
68
  readonly #buffered: Frame[] = [];
66
69
  #askCursor = 0;
70
+ #compactionCursor = 0;
67
71
  #mode: "replay" | "activating" | "live" = "replay";
68
72
  #live: AgentTransport | null = null;
69
73
  #activation: Promise<void> | null = null;
@@ -112,6 +116,27 @@ class ResumeTransport implements AgentTransport {
112
116
  if (this.#mode === "live") this.#live?.finishAsk(completion);
113
117
  }
114
118
 
119
+ /**
120
+ * A compaction the Cassette does not hold activates live, exactly as a
121
+ * diverged Ask does: the recorded prefix cannot answer it (ADR-0043).
122
+ */
123
+ beginCompaction(marker: CompactionMarker): CompactionPlayback | undefined {
124
+ if (this.#mode === "live") return this.#live?.beginCompaction(marker);
125
+ if (this.#mode === "activating") return undefined;
126
+ const mismatch = replayMismatch.compaction(this.#agent, this.#compactionCursor, marker);
127
+ if (!mismatch) {
128
+ this.#compactionCursor += 1;
129
+ return this.#replay.beginCompaction(marker);
130
+ }
131
+ this.#mode = "activating";
132
+ this.#activation = this.#activate(null);
133
+ return undefined;
134
+ }
135
+
136
+ finishCompaction(result: CompactionResult | undefined): void {
137
+ if (this.#mode === "live") this.#live?.finishCompaction(result);
138
+ }
139
+
115
140
  recordedExtractionPolicy(index: number): string | undefined {
116
141
  return this.#mode === "replay" ? this.#agent.asks[index]?.extractionPolicy : undefined;
117
142
  }
@@ -121,7 +146,7 @@ class ResumeTransport implements AgentTransport {
121
146
  return this.#closed;
122
147
  }
123
148
 
124
- async #activate(marker: AskMarker): Promise<void> {
149
+ async #activate(marker: AskMarker | null): Promise<void> {
125
150
  // End the old source before attaching the new one, but keep the outward
126
151
  // queue open so Connection remains attached across the handoff.
127
152
  this.#replay.finish();
@@ -131,7 +156,7 @@ class ResumeTransport implements AgentTransport {
131
156
  try {
132
157
  const live = await this.#liveFactory.open(this.#continuation);
133
158
  this.#live = live;
134
- live.beginAsk(marker);
159
+ if (marker !== null) live.beginAsk(marker);
135
160
  for (const frame of this.#buffered.splice(0)) live.send(frame);
136
161
  this.#mode = "live";
137
162
  void this.#forward(live.frames());
package/src/errors.ts CHANGED
@@ -44,6 +44,10 @@ export type YaagErrorCode =
44
44
  | "ASK_TIMEOUT" // timeoutMs elapsed
45
45
  | "ASK_LIMIT" // soft Ask budget exceeded after grace
46
46
  | "ASK_STALLED" // no frame arrived within the silence budget; escalated, then kill
47
+ | "COMPACT_DURING_ASK" // compact() was called while an Ask was in flight
48
+ | "COMPACT_FAILED" // pi refused or could not summarize the context
49
+ | "FORK_DURING_ASK" // fork() was called while the source Ask was in flight
50
+ | "FORK_REFUSED" // the fork source died mid-Ask, or holds no session file
47
51
  | "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
48
52
  | "ARGS_INVALID" // arguments failed schema validation before the Run started
49
53
  | "CONFIG_INVALID" // a config layer is missing, unparsable, or violates the schema
package/src/events.ts CHANGED
@@ -20,6 +20,12 @@ export type { ModelErrorReason } from "./model/index.ts";
20
20
  */
21
21
  export type RunOutcome = "completed" | "failed" | "stopped" | "paused" | "interrupted";
22
22
 
23
+ /**
24
+ * How an Agent came to be. An absent value on an event means "spawn"; "fork"
25
+ * marks an Agent created from a copy of another Agent's session (ADR-0044).
26
+ */
27
+ export type SpawnOrigin = "spawn" | "fork";
28
+
23
29
  /** The current, Ask-scoped observer projection derived from Agent frames. */
24
30
  export type AgentActivity =
25
31
  | { readonly type: "thinking" }
@@ -65,6 +71,10 @@ export type LifecycleEventBody =
65
71
  * Cassette-playback Agents and events from older CLIs.
66
72
  */
67
73
  readonly sessionFile?: string;
74
+ /** Resolved name of the Agent named as this Agent's parent (Parent Link). */
75
+ readonly parent?: string;
76
+ /** How the Agent came to be; absent means "spawn". */
77
+ readonly origin?: SpawnOrigin;
68
78
  }
69
79
  | {
70
80
  readonly type: "ask_start";
@@ -159,6 +169,31 @@ export type LifecycleEventBody =
159
169
  /** Cumulative assistant-completion cost observed so far. */
160
170
  readonly cost: number;
161
171
  }
172
+ | {
173
+ /**
174
+ * One compaction of an Agent's context (ADR-0043).
175
+ *
176
+ * It carries the summary call's own spend, so a Run Summary does not
177
+ * silently lose it. `agent_exit` stays authoritative for the Agent's
178
+ * total: pi's `get_session_stats` already includes compaction spend.
179
+ * The custom instructions never ride the wire (ticket 09); only the
180
+ * `custom` flag says that the program supplied some.
181
+ */
182
+ readonly type: "agent_compaction";
183
+ readonly agent: string;
184
+ /** Monotonic per Agent, from 0. */
185
+ readonly index: number;
186
+ /** Context tokens before the summary replaced the transcript; null when unknown. */
187
+ readonly tokensBefore: number | null;
188
+ /** Estimated context tokens after the summary; null when unknown. */
189
+ readonly tokensAfter: number | null;
190
+ /** The summary call's own token breakdown; null when unknown. */
191
+ readonly tokens: TokenBreakdown | null;
192
+ /** The summary call's own cost; null when unknown. */
193
+ readonly cost: number | null;
194
+ /** The program supplied custom compaction instructions. */
195
+ readonly custom: boolean;
196
+ }
162
197
  | {
163
198
  readonly type: "agent_exit";
164
199
  readonly agent: string;
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  CassetteAgent,
6
6
  CassetteArtifact,
7
7
  CassetteAsk,
8
+ CassetteCompaction,
8
9
  CassetteGit,
9
10
  CassetteRun,
10
11
  CassetteSink,
@@ -47,6 +48,7 @@ export type {
47
48
  LifecycleEventBody,
48
49
  NodeState,
49
50
  NodeUsage,
51
+ SpawnOrigin,
50
52
  StampedEventSink,
51
53
  } from "./events.ts";
52
54
  export type {
@@ -78,6 +80,7 @@ export type {
78
80
  AgentInfo,
79
81
  AgentState,
80
82
  AskingAgentInfo,
83
+ CompactionInfo,
81
84
  EndedRunSummary,
82
85
  ExitedAgentInfo,
83
86
  IdleAgentInfo,
@@ -95,6 +98,9 @@ export type {
95
98
  AskMarker,
96
99
  AskMarkerContext,
97
100
  AskPlayback,
101
+ CompactionMarker,
102
+ CompactionPlayback,
103
+ CompactionResult,
98
104
  DiscoveredSkill,
99
105
  Frame,
100
106
  ReapPath,
@@ -109,6 +115,7 @@ export type {
109
115
  export { readSystemPromptSidecar, reap, worktreeTransport } from "./transport/index.ts";
110
116
  export type {
111
117
  AskOptions,
118
+ ForkOptions,
112
119
  Handle,
113
120
  ResolvedSpawnOptions,
114
121
  SpawnOptions,
@@ -16,8 +16,10 @@ export type {
16
16
  AgentInfo,
17
17
  AgentState,
18
18
  AskingAgentInfo,
19
+ CompactionInfo,
19
20
  ExitedAgentInfo,
20
21
  IdleAgentInfo,
21
22
  ModelFallbackInfo,
22
23
  NodeInfo,
24
+ SpawnOrigin,
23
25
  } from "./summary-agent.ts";
@@ -1,12 +1,21 @@
1
- import type { AgentActivity } from "../events.ts";
1
+ import type { AgentActivity, SpawnOrigin } from "../events.ts";
2
2
  import type { TokenBreakdown, WorktreeResolution } from "../transport/index.ts";
3
3
  import type { ModelFallbackInfo } from "./summary-fallbacks.ts";
4
4
  import type { NodeInfo } from "./summary-nodes.ts";
5
5
 
6
- export type { AgentActivity } from "../events.ts";
6
+ export type { AgentActivity, SpawnOrigin } from "../events.ts";
7
7
  export type { ModelFallbackInfo } from "./summary-fallbacks.ts";
8
8
  export type { NodeInfo } from "./summary-nodes.ts";
9
9
 
10
+ /** What one compaction of an Agent's context reported (ADR-0043). */
11
+ export interface CompactionInfo {
12
+ readonly tokensBefore: number | null;
13
+ readonly tokensAfter: number | null;
14
+ /** The summary call's own cost, already included in `agent_exit` accounting. */
15
+ readonly cost: number | null;
16
+ readonly at: number | null;
17
+ }
18
+
10
19
  /** The observer-facing lifecycle state of an Agent. */
11
20
  export type AgentState = "idle" | "asking" | "exited";
12
21
 
@@ -22,6 +31,10 @@ interface AgentInfoBase {
22
31
  readonly branch: string | null;
23
32
  /** pi's session file for this Agent, when the spawn reported one; a Peek reads it. */
24
33
  readonly sessionFile: string | null;
34
+ /** The Agent named as this one's parent (Parent Link), or null for a root Agent. */
35
+ readonly parent: string | null;
36
+ /** How the Agent came to be; "spawn" until forking ships. */
37
+ readonly origin: SpawnOrigin;
25
38
  readonly activity: AgentActivity | null;
26
39
  readonly tokens: TokenBreakdown | null;
27
40
  readonly cost: number | null;
@@ -30,6 +43,10 @@ interface AgentInfoBase {
30
43
  /** Timestamp ordering only cumulative live accounting, never lifecycle state. */
31
44
  readonly usageUpdatedAt: number | null;
32
45
  readonly askStartedAt: number | null;
46
+ /** Compactions of this Agent's context so far (ADR-0043). */
47
+ readonly compactions: number;
48
+ /** What the newest compaction reported, or null when the Agent compacted none. */
49
+ readonly lastCompaction: CompactionInfo | null;
33
50
  /** This Agent's bounded Nested Node table, in first-seen order (spec §3). */
34
51
  readonly nodes: readonly NodeInfo[];
35
52
  /** Exited Nested Nodes dropped to keep the table bounded. */
@@ -97,6 +114,10 @@ export function placeholderAgent(): IdleAgentInfo {
97
114
  cwd: null,
98
115
  branch: null,
99
116
  sessionFile: null,
117
+ parent: null,
118
+ origin: "spawn",
119
+ compactions: 0,
120
+ lastCompaction: null,
100
121
  state: "idle",
101
122
  askIndex: null,
102
123
  promptGist: null,
@@ -125,6 +146,8 @@ export function spawnAgent(
125
146
  readonly cwd: string;
126
147
  readonly branch?: string;
127
148
  readonly sessionFile?: string;
149
+ readonly parent?: string;
150
+ readonly origin?: SpawnOrigin;
128
151
  },
129
152
  at: number | null,
130
153
  ): AgentRecord {
@@ -135,6 +158,8 @@ export function spawnAgent(
135
158
  cwd: identity.cwd,
136
159
  branch: identity.branch ?? null,
137
160
  sessionFile: identity.sessionFile ?? null,
161
+ parent: identity.parent ?? null,
162
+ origin: identity.origin ?? "spawn",
138
163
  stateChangedAt: at,
139
164
  };
140
165
  }
@@ -144,6 +169,11 @@ export function spawnAgent(
144
169
  cwd: current.cwd ?? identity.cwd,
145
170
  branch: current.branch ?? identity.branch ?? null,
146
171
  sessionFile: current.sessionFile ?? identity.sessionFile ?? null,
172
+ // Lineage follows the rule above it: a fact already folded wins, and a late
173
+ // spawn only fills what is still missing. `origin` has no missing value —
174
+ // it defaults to "spawn" — so the spawn event is its only authority.
175
+ parent: current.parent ?? identity.parent ?? null,
176
+ origin: identity.origin ?? current.origin,
147
177
  };
148
178
  }
149
179
 
@@ -249,6 +279,29 @@ export function setUsage(
249
279
  return { ...agent, tokens: observation.tokens, cost: observation.cost, usageUpdatedAt: at };
250
280
  }
251
281
 
282
+ /**
283
+ * Counts one compaction and keeps what it reported.
284
+ *
285
+ * It folds no cost: the summary call's spend reaches the Summary through
286
+ * `agent_usage`, and `agent_exit` stays authoritative (ADR-0043). A terminal
287
+ * Agent still counts a late compaction, because the count is a fact about the
288
+ * conversation rather than a lifecycle state.
289
+ */
290
+ export function compactAgent(
291
+ current: AgentRecord | undefined,
292
+ observation: CompactionInfo,
293
+ at: number | null,
294
+ ): AgentRecord {
295
+ const agent = current ?? placeholderAgent();
296
+ const stale =
297
+ at !== null && agent.lastCompaction?.at !== null && at < (agent.lastCompaction?.at ?? at);
298
+ return {
299
+ ...agent,
300
+ compactions: agent.compactions + 1,
301
+ lastCompaction: stale ? agent.lastCompaction : { ...observation, at },
302
+ };
303
+ }
304
+
252
305
  /**
253
306
  * Folds authoritative shutdown accounting into the terminal Agent state.
254
307
  *
@@ -3,6 +3,7 @@ import type { TokenBreakdown } from "../transport/index.ts";
3
3
  import type { AgentInfo } from "./summary-agent.ts";
4
4
  import {
5
5
  type AgentRecord,
6
+ compactAgent,
6
7
  endAsk,
7
8
  exitAgent,
8
9
  placeholderAgent,
@@ -22,10 +23,12 @@ export type {
22
23
  AgentInfo,
23
24
  AgentState,
24
25
  AskingAgentInfo,
26
+ CompactionInfo,
25
27
  ExitedAgentInfo,
26
28
  IdleAgentInfo,
27
29
  ModelFallbackInfo,
28
30
  NodeInfo,
31
+ SpawnOrigin,
29
32
  } from "./summary-agent.ts";
30
33
 
31
34
  /** The observer-facing lifecycle state of a Run. */
@@ -50,6 +53,8 @@ interface RunSummaryBase {
50
53
  readonly worstFrameGapMs: number;
51
54
  /** Run-wide Model Resolution fallbacks, pruned per-Agent entries included. */
52
55
  readonly modelFallbacks: number;
56
+ /** Compactions across every Agent of this Run (ADR-0043). */
57
+ readonly compactions: number;
53
58
  }
54
59
 
55
60
  /** A Run that has not settled; it has neither outcome nor compatibility result. */
@@ -108,6 +113,7 @@ export function initialSummary(): RunningRunSummary {
108
113
  durationMs: 0,
109
114
  worstFrameGapMs: 0,
110
115
  modelFallbacks: 0,
116
+ compactions: 0,
111
117
  ok: null,
112
118
  };
113
119
  }
@@ -159,6 +165,21 @@ export function applyEvent(
159
165
  event.agent,
160
166
  applyAgentModel(summary.agents[event.agent] ?? placeholderAgent(), event),
161
167
  );
168
+ case "agent_compaction":
169
+ return withAgent(
170
+ { ...summary, compactions: summary.compactions + 1 },
171
+ event.agent,
172
+ compactAgent(
173
+ summary.agents[event.agent],
174
+ {
175
+ tokensBefore: event.tokensBefore,
176
+ tokensAfter: event.tokensAfter,
177
+ cost: event.cost,
178
+ at,
179
+ },
180
+ at,
181
+ ),
182
+ );
162
183
  case "agent_usage":
163
184
  return withAgent(summary, event.agent, setUsage(summary.agents[event.agent], event, at));
164
185
  case "ask_end":
@@ -6,7 +6,13 @@ import {
6
6
  import type { AvailableModel } from "../model/index.ts";
7
7
  import { FrameQueue } from "./frame-queue.ts";
8
8
  import { parseFrame } from "./jsonl.ts";
9
- import type { AgentStats, AgentTransport, AskMarker, Frame } from "./transport.ts";
9
+ import type {
10
+ AgentStats,
11
+ AgentTransport,
12
+ AskMarker,
13
+ CompactionMarker,
14
+ Frame,
15
+ } from "./transport.ts";
10
16
 
11
17
  /**
12
18
  * Scripted frame playback for one prompt sent through a FakeTransport.
@@ -50,6 +56,10 @@ export interface FakeTransportOptions extends FakePromptScript {
50
56
  readonly steerError?: string;
51
57
  /** Makes an abort RPC response fail without embedding a limit decision in playback. */
52
58
  readonly abortError?: string;
59
+ /** Payload of a `compact` command response; omission answers a bare success. */
60
+ readonly compaction?: Record<string, unknown>;
61
+ /** Makes a `compact` command response fail, as pi does when it cannot summarize. */
62
+ readonly compactError?: string;
53
63
  /** Makes the private report_result schema command fail. */
54
64
  readonly schemaCommandError?: string;
55
65
  /** The snapshot answered to `get_available_models`; defaults to this fake's own model. */
@@ -87,6 +97,7 @@ export class FakeTransport implements AgentTransport {
87
97
  /** Everything the runtime wrote, in order. */
88
98
  readonly sent: Frame[] = [];
89
99
  readonly asks: AskMarker[] = [];
100
+ readonly compactions: CompactionMarker[] = [];
90
101
  readonly #queue = new FrameQueue();
91
102
  readonly #options: FakeTransportOptions;
92
103
  #closed = false;
@@ -113,6 +124,7 @@ export class FakeTransport implements AgentTransport {
113
124
  if (frame.type === "abort") void this.#answerAbort(frame);
114
125
  if (frame.type === "get_last_assistant_text") this.#answerLastText(frame);
115
126
  if (frame.type === "get_state") this.#answerState(frame);
127
+ if (frame.type === "compact") this.#answerCompact(frame);
116
128
  if (frame.type === "get_available_models") this.#answerAvailableModels(frame);
117
129
  if (frame.type === "set_model") this.#answerSetModel(frame);
118
130
  if (frame.type === "set_thinking_level")
@@ -133,6 +145,15 @@ export class FakeTransport implements AgentTransport {
133
145
  // This live test transport does not persist Ask outcomes.
134
146
  }
135
147
 
148
+ beginCompaction(marker: CompactionMarker): undefined {
149
+ this.compactions.push(marker);
150
+ return undefined;
151
+ }
152
+
153
+ finishCompaction(): void {
154
+ // This live test transport does not persist compaction outcomes.
155
+ }
156
+
136
157
  /** True once the runtime shut this Agent down. */
137
158
  get closed(): boolean {
138
159
  return this.#closed;
@@ -295,6 +316,19 @@ export class FakeTransport implements AgentTransport {
295
316
  });
296
317
  }
297
318
 
319
+ #answerCompact(frame: Frame): void {
320
+ const error = this.#options.compactError;
321
+ this.#queue.push({
322
+ type: "response",
323
+ command: "compact",
324
+ id: frame.id,
325
+ success: error === undefined,
326
+ ...(error === undefined
327
+ ? { data: this.#options.compaction ?? { summary: "summary" } }
328
+ : { error }),
329
+ });
330
+ }
331
+
298
332
  #answerState(frame: Frame): void {
299
333
  if (this.#options.stateSilent === true) return;
300
334
  this.#queue.push({
@@ -17,7 +17,12 @@ export { FrameGapTracker } from "./frame-gap.ts";
17
17
  export { FrameQueue } from "./frame-queue.ts";
18
18
  export { decodeFrames } from "./jsonl.ts";
19
19
  export { liveTransport } from "./live-transport.ts";
20
- export { type AgentProgress, piCommand, readAgentProgress } from "./pi-state.ts";
20
+ export {
21
+ type AgentProgress,
22
+ piCommand,
23
+ readAgentProgress,
24
+ readCompaction,
25
+ } from "./pi-state.ts";
21
26
  export { type ReapPath, type ReapTarget, reap } from "./reap.ts";
22
27
  export { type DiscoveredSkill, liveSkillProbe, type SkillProbeFactory } from "./skill-probe.ts";
23
28
  export { skillRestrictionTransport } from "./skill-restriction-transport.ts";
@@ -34,6 +39,9 @@ export type {
34
39
  AskMarker,
35
40
  AskMarkerContext,
36
41
  AskPlayback,
42
+ CompactionMarker,
43
+ CompactionPlayback,
44
+ CompactionResult,
37
45
  Frame,
38
46
  OpenOptions,
39
47
  TokenBreakdown,
@@ -140,6 +140,15 @@ class LiveTransport implements AgentTransport {
140
140
  // Live transport has no Cassette outcome to complete.
141
141
  }
142
142
 
143
+ beginCompaction(): undefined {
144
+ // Nothing to do live; a recording implementation groups frames by this.
145
+ return undefined;
146
+ }
147
+
148
+ finishCompaction(): void {
149
+ // Live transport has no Cassette compaction to complete.
150
+ }
151
+
143
152
  close(): Promise<AgentStats> {
144
153
  this.#closing ??= this.#shutdown();
145
154
  return this.#closing;