pi-plans 0.1.2 → 0.2.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.
package/src/subagent.ts CHANGED
@@ -9,6 +9,20 @@ import * as fs from "node:fs";
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
 
12
+ export type SubagentProgressEvent =
13
+ | { type: "process"; phase: "started" | "exited"; code?: number }
14
+ | { type: "turn"; phase: "start" | "end" }
15
+ | { type: "message"; phase: "start" | "update" | "end"; role: string; text: string }
16
+ | {
17
+ type: "tool";
18
+ phase: "start" | "update" | "end";
19
+ toolCallId: string;
20
+ toolName: string;
21
+ detail: string;
22
+ isError?: boolean;
23
+ }
24
+ | { type: "stderr"; text: string };
25
+
12
26
  export interface SubagentOptions {
13
27
  systemPrompt: string;
14
28
  task: string;
@@ -19,6 +33,8 @@ export interface SubagentOptions {
19
33
  tools?: string[];
20
34
  signal?: AbortSignal;
21
35
  timeoutMs?: number;
36
+ /** Optional normalized progress sink. Exceptions from the sink are ignored. */
37
+ onProgress?: (event: SubagentProgressEvent) => void;
22
38
  }
23
39
 
24
40
  export interface SubagentResult {
@@ -28,6 +44,8 @@ export interface SubagentResult {
28
44
  errorMessage?: string;
29
45
  stderr: string;
30
46
  turns: number;
47
+ cancelled?: boolean;
48
+ timedOut?: boolean;
31
49
  }
32
50
 
33
51
  /** Strip YAML frontmatter from an agent definition file. */
@@ -54,43 +72,132 @@ export function getPiInvocation(args: string[]): { command: string; args: string
54
72
 
55
73
  interface MessageLike {
56
74
  role: string;
57
- content: Array<{ type: string; text?: string }>;
75
+ content?: Array<{ type: string; text?: string; thinking?: string }> | string;
58
76
  model?: string;
59
77
  }
60
78
 
79
+ interface RawSubagentEvent {
80
+ type?: unknown;
81
+ message?: MessageLike;
82
+ toolCallId?: unknown;
83
+ toolName?: unknown;
84
+ args?: unknown;
85
+ partialResult?: unknown;
86
+ result?: unknown;
87
+ isError?: unknown;
88
+ assistantMessageEvent?: unknown;
89
+ }
90
+
61
91
  function finalOutput(messages: MessageLike[]): string {
62
92
  for (let i = messages.length - 1; i >= 0; i--) {
63
93
  const message = messages[i];
64
94
  if (message.role === "assistant") {
65
- const text = message.content
66
- .filter((part) => part.type === "text")
67
- .map((part) => part.text ?? "")
68
- .join("\n")
69
- .trim();
95
+ const text = messageText(message, "text").trim();
70
96
  if (text) return text;
71
97
  }
72
98
  }
73
99
  return "";
74
100
  }
75
101
 
102
+ function messageText(message: MessageLike | undefined, kind: "text" | "thinking" = "text"): string {
103
+ if (!message?.content) return "";
104
+ if (typeof message.content === "string") return kind === "text" ? message.content : "";
105
+ return message.content
106
+ .filter((part) => part.type === kind)
107
+ .map((part) => (kind === "thinking" ? part.thinking ?? part.text ?? "" : part.text ?? ""))
108
+ .join("\n");
109
+ }
110
+
111
+ function messageEventText(value: unknown): string {
112
+ if (!value || typeof value !== "object") return "";
113
+ const event = value as { delta?: unknown; content?: unknown };
114
+ return typeof event.delta === "string" ? event.delta : typeof event.content === "string" ? event.content : "";
115
+ }
116
+
117
+ function preview(value: unknown, maxLength = 240): string {
118
+ if (typeof value === "string") return value.slice(0, maxLength);
119
+ if (value === undefined) return "";
120
+ try {
121
+ const text = JSON.stringify(value);
122
+ return text ? text.slice(0, maxLength) : "";
123
+ } catch {
124
+ return String(value).slice(0, maxLength);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Normalize the JSONL events emitted by `pi --mode json` into the small
130
+ * protocol consumed by the refinement overlay. Unknown events are ignored so
131
+ * adding progress support cannot make result parsing version-fragile.
132
+ */
133
+ export function normalizeSubagentEvent(value: unknown): SubagentProgressEvent | undefined {
134
+ if (!value || typeof value !== "object") return undefined;
135
+ const event = value as RawSubagentEvent;
136
+ if (typeof event.type !== "string") return undefined;
137
+
138
+ if (event.type === "turn_start") return { type: "turn", phase: "start" };
139
+ if (event.type === "turn_end") return { type: "turn", phase: "end" };
140
+
141
+ if (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") {
142
+ if (!event.message && !event.assistantMessageEvent) return undefined;
143
+ const role = event.message?.role ?? "assistant";
144
+ return {
145
+ type: "message",
146
+ phase: event.type.slice("message_".length) as "start" | "update" | "end",
147
+ role,
148
+ text: messageText(event.message) || messageEventText(event.assistantMessageEvent),
149
+ };
150
+ }
151
+
152
+ const toolEvent =
153
+ event.type === "tool_execution_start"
154
+ ? "start"
155
+ : event.type === "tool_execution_update"
156
+ ? "update"
157
+ : event.type === "tool_execution_end" || event.type === "tool_result_end"
158
+ ? "end"
159
+ : null;
160
+ if (toolEvent) {
161
+ return {
162
+ type: "tool",
163
+ phase: toolEvent,
164
+ toolCallId: typeof event.toolCallId === "string" ? event.toolCallId : "unknown",
165
+ toolName: typeof event.toolName === "string" ? event.toolName : "tool",
166
+ detail: preview(event.args ?? event.partialResult ?? event.result),
167
+ ...(typeof event.isError === "boolean" ? { isError: event.isError } : {}),
168
+ };
169
+ }
170
+
171
+ return undefined;
172
+ }
173
+
174
+ function emitProgress(options: SubagentOptions, event: SubagentProgressEvent): void {
175
+ try {
176
+ options.onProgress?.(event);
177
+ } catch {
178
+ // A display sink must not be able to fail the child runner.
179
+ }
180
+ }
181
+
76
182
  const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
77
183
 
78
184
  export async function runPiSubagent(options: SubagentOptions): Promise<SubagentResult> {
79
185
  const tools = options.tools ?? ["read", "grep", "find", "ls"];
80
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-subagent-"));
81
- const promptFile = path.join(tmpDir, "system-prompt.md");
82
- fs.writeFileSync(promptFile, options.systemPrompt, { encoding: "utf8", mode: 0o600 });
83
-
84
- const args: string[] = ["--mode", "json", "-p", "--no-session", "--tools", tools.join(",")];
85
- if (options.model) args.push("--model", options.model);
86
- args.push("--append-system-prompt", promptFile);
87
- args.push(`Task: ${options.task}`);
88
-
186
+ let tmpDir = "";
89
187
  const messages: MessageLike[] = [];
90
188
  let stderr = "";
91
- let wasAborted = false;
189
+ let termination: "abort" | "timeout" | null = null;
92
190
 
93
191
  try {
192
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-subagent-"));
193
+ const promptFile = path.join(tmpDir, "system-prompt.md");
194
+ fs.writeFileSync(promptFile, options.systemPrompt, { encoding: "utf8", mode: 0o600 });
195
+
196
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--tools", tools.join(",")];
197
+ if (options.model) args.push("--model", options.model);
198
+ args.push("--append-system-prompt", promptFile);
199
+ args.push(`Task: ${options.task}`);
200
+
94
201
  const exitCode = await new Promise<number>((resolve) => {
95
202
  const invocation = getPiInvocation(args);
96
203
  const proc = spawn(invocation.command, invocation.args, {
@@ -99,20 +206,61 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
99
206
  stdio: ["ignore", "pipe", "pipe"],
100
207
  });
101
208
  let buffer = "";
209
+ let closed = false;
210
+ let settled = false;
211
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
212
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
213
+
214
+ const onAbort = () => {
215
+ if (termination === null) termination = "abort";
216
+ killProc();
217
+ };
218
+
219
+ const cleanup = () => {
220
+ if (timeoutTimer) clearTimeout(timeoutTimer);
221
+ if (forceKillTimer) clearTimeout(forceKillTimer);
222
+ options.signal?.removeEventListener("abort", onAbort);
223
+ };
224
+
225
+ const finish = (code: number) => {
226
+ if (settled) return;
227
+ settled = true;
228
+ closed = true;
229
+ cleanup();
230
+ emitProgress(options, { type: "process", phase: "exited", code });
231
+ resolve(code);
232
+ };
102
233
 
103
234
  const processLine = (line: string) => {
104
235
  if (!line.trim()) return;
105
- let event: { type?: string; message?: MessageLike };
236
+ let event: RawSubagentEvent;
106
237
  try {
107
- event = JSON.parse(line);
238
+ event = JSON.parse(line) as RawSubagentEvent;
108
239
  } catch {
109
240
  return;
110
241
  }
242
+ const progress = normalizeSubagentEvent(event);
243
+ if (progress) emitProgress(options, progress);
111
244
  if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
112
245
  messages.push(event.message);
113
246
  }
114
247
  };
115
248
 
249
+ const killProc = () => {
250
+ if (settled || termination === null) return;
251
+ if (!proc.killed) proc.kill("SIGTERM");
252
+ if (!forceKillTimer) {
253
+ forceKillTimer = setTimeout(() => {
254
+ try {
255
+ if (!closed) proc.kill("SIGKILL");
256
+ } catch {
257
+ // The process already exited.
258
+ }
259
+ }, 5000);
260
+ }
261
+ };
262
+
263
+ emitProgress(options, { type: "process", phase: "started" });
116
264
  proc.stdout.on("data", (data) => {
117
265
  buffer += data.toString();
118
266
  const lines = buffer.split("\n");
@@ -120,78 +268,57 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
120
268
  for (const line of lines) processLine(line);
121
269
  });
122
270
  proc.stderr.on("data", (data) => {
123
- stderr += data.toString();
271
+ const text = data.toString();
272
+ stderr += text;
273
+ emitProgress(options, { type: "stderr", text });
124
274
  });
125
275
  proc.on("close", (code) => {
126
276
  if (buffer.trim()) processLine(buffer);
127
- resolve(code ?? 0);
277
+ finish(code ?? 0);
278
+ });
279
+ proc.on("error", (error) => {
280
+ stderr += error instanceof Error ? error.message : String(error);
281
+ finish(1);
128
282
  });
129
- proc.on("error", () => resolve(1));
130
-
131
- const killProc = () => {
132
- wasAborted = true;
133
- proc.kill("SIGTERM");
134
- setTimeout(() => {
135
- try {
136
- if (!proc.killed) proc.kill("SIGKILL");
137
- } catch {
138
- /* already gone */
139
- }
140
- }, 5000);
141
- };
142
283
 
143
- const timer = setTimeout(killProc, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
144
- const onAbort = () => killProc();
284
+ timeoutTimer = setTimeout(() => {
285
+ if (termination === null) termination = "timeout";
286
+ killProc();
287
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
145
288
  if (options.signal) {
146
- if (options.signal.aborted) killProc();
289
+ if (options.signal.aborted) onAbort();
147
290
  else options.signal.addEventListener("abort", onAbort, { once: true });
148
291
  }
149
- proc.on("close", () => {
150
- clearTimeout(timer);
151
- options.signal?.removeEventListener("abort", onAbort);
152
- });
153
292
  });
154
293
 
155
- if (wasAborted) {
156
- return {
157
- ok: false,
158
- output: finalOutput(messages),
159
- stderr,
160
- turns: messages.filter((m) => m.role === "assistant").length,
161
- errorMessage: "Subagent was aborted",
162
- };
294
+ const turns = messages.filter((message) => message.role === "assistant").length;
295
+ const output = finalOutput(messages);
296
+ if (termination === "abort") {
297
+ return { ok: false, output, stderr, turns, cancelled: true, errorMessage: "Subagent was aborted" };
298
+ }
299
+ if (termination === "timeout") {
300
+ return { ok: false, output, stderr, turns, timedOut: true, errorMessage: "Subagent timed out" };
163
301
  }
164
302
  if (exitCode !== 0) {
165
- return {
166
- ok: false,
167
- output: finalOutput(messages),
168
- stderr,
169
- turns: messages.filter((m) => m.role === "assistant").length,
170
- errorMessage: `pi exited with code ${exitCode}`,
171
- };
303
+ return { ok: false, output, stderr, turns, errorMessage: `pi exited with code ${exitCode}` };
172
304
  }
173
- const output = finalOutput(messages);
174
305
  if (!output) {
175
- return {
176
- ok: false,
177
- output: "",
178
- stderr,
179
- turns: messages.filter((m) => m.role === "assistant").length,
180
- errorMessage: "subagent produced no final output",
181
- };
306
+ return { ok: false, output: "", stderr, turns, errorMessage: "subagent produced no final output" };
182
307
  }
183
308
  return {
184
309
  ok: true,
185
310
  output,
186
- model: [...messages].reverse().find((m) => m.role === "assistant" && m.model)?.model,
311
+ model: [...messages].reverse().find((message) => message.role === "assistant" && message.model)?.model,
187
312
  stderr,
188
- turns: messages.filter((m) => m.role === "assistant").length,
313
+ turns,
189
314
  };
190
315
  } finally {
191
- try {
192
- fs.rmSync(tmpDir, { recursive: true, force: true });
193
- } catch {
194
- /* best effort */
316
+ if (tmpDir) {
317
+ try {
318
+ fs.rmSync(tmpDir, { recursive: true, force: true });
319
+ } catch {
320
+ /* best effort */
321
+ }
195
322
  }
196
323
  }
197
324
  }
@@ -0,0 +1,142 @@
1
+ /** Tests for run-scoped Auto-complete and planning continuation. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { after, before, describe, it } from "node:test";
8
+ import {
9
+ autoCompleteStatus,
10
+ disableAutoComplete,
11
+ enableAutoComplete,
12
+ isAutoCompleteEnabled,
13
+ markPlanWritten,
14
+ recordAskChoice,
15
+ registerAutoCompleteTurnHandlers,
16
+ restoreAutoCompleteFromSession,
17
+ setAutoCompleteApi,
18
+ } from "../src/autocomplete.ts";
19
+ import { initState, setRunStatus, startRun } from "../src/state.ts";
20
+
21
+ let root: string;
22
+
23
+ function makeRun(name: string): { workdir: string; runId: string } {
24
+ const workdir = path.join(root, name);
25
+ fs.mkdirSync(workdir, { recursive: true });
26
+ initState(workdir);
27
+ const { run } = startRun(workdir, { topic: name, skill: "plan-small", requestText: "test" });
28
+ return { workdir, runId: run.run_id };
29
+ }
30
+
31
+ function makeContext(workdir: string, sessionManager: any = {}): any {
32
+ return {
33
+ cwd: workdir,
34
+ hasUI: true,
35
+ mode: "tui",
36
+ sessionManager,
37
+ ui: {
38
+ select: async (_question: string, options: string[]) => options.at(-1),
39
+ input: async () => "typed",
40
+ },
41
+ };
42
+ }
43
+
44
+ before(() => {
45
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-autocomplete-"));
46
+ });
47
+
48
+ after(() => {
49
+ setAutoCompleteApi(null);
50
+ fs.rmSync(root, { recursive: true, force: true });
51
+ });
52
+
53
+ describe("Auto-complete state", () => {
54
+ it("enables only for the active planning run and restores only while planning", () => {
55
+ const { workdir, runId } = makeRun("restore");
56
+ const entries: any[] = [];
57
+ setAutoCompleteApi({ appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }) } as any);
58
+ const ctx = makeContext(workdir);
59
+
60
+ assert.equal(enableAutoComplete(ctx), true);
61
+ assert.equal(autoCompleteStatus(ctx), "enabled");
62
+ assert.equal(entries.at(-1)?.data.runId, runId);
63
+
64
+ const restored = makeContext(workdir);
65
+ restoreAutoCompleteFromSession(restored, entries);
66
+ assert.equal(isAutoCompleteEnabled(restored), true);
67
+
68
+ setRunStatus(workdir, runId, "done");
69
+ const finished = makeContext(workdir);
70
+ restoreAutoCompleteFromSession(finished, entries);
71
+ assert.equal(autoCompleteStatus(finished), "disabled");
72
+
73
+ const stoppedEntries = [...entries, { type: "custom", customType: "pi-plans-autocomplete", data: { runId, enabled: false } }];
74
+ const stopped = makeContext(workdir);
75
+ restoreAutoCompleteFromSession(stopped, stoppedEntries);
76
+ assert.equal(autoCompleteStatus(stopped), "disabled");
77
+ });
78
+
79
+ it("clears explicitly and marks plan writes as a continuation boundary", () => {
80
+ const { workdir } = makeRun("clear");
81
+ const entries: any[] = [];
82
+ setAutoCompleteApi({ appendEntry: (customType: string, data: unknown) => entries.push({ customType, data }) } as any);
83
+ const ctx = makeContext(workdir);
84
+ enableAutoComplete(ctx);
85
+ markPlanWritten(ctx);
86
+ recordAskChoice(ctx, true);
87
+ assert.equal(disableAutoComplete(ctx, "test"), true);
88
+ assert.equal(autoCompleteStatus(ctx), "disabled");
89
+ assert.equal(entries.at(-1)?.data.enabled, false);
90
+ });
91
+ });
92
+
93
+ describe("Auto-complete continuation", () => {
94
+ it("queues one follow-up after an early stop and none after a natural next question", async () => {
95
+ const { workdir } = makeRun("continuation");
96
+ const session = {};
97
+ const sent: any[] = [];
98
+ const handlers = new Map<string, Function[]>();
99
+ const pi: any = {
100
+ on: (name: string, handler: Function) => handlers.set(name, [...(handlers.get(name) ?? []), handler]),
101
+ appendEntry: () => {},
102
+ sendUserMessage: async (content: string, options: unknown) => sent.push({ content, options }),
103
+ };
104
+ setAutoCompleteApi(pi);
105
+ registerAutoCompleteTurnHandlers(pi);
106
+ const ctx = makeContext(workdir, session);
107
+ enableAutoComplete(ctx);
108
+ await handlers.get("turn_start")?.[0]?.({}, ctx);
109
+ recordAskChoice(ctx, true);
110
+ await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
111
+ await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
112
+ assert.equal(sent.length, 1);
113
+ assert.equal((sent[0]!.options as any).deliverAs, "followUp");
114
+
115
+ await handlers.get("turn_start")?.[0]?.({}, ctx);
116
+ recordAskChoice(ctx, true);
117
+ markPlanWritten(ctx);
118
+ await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
119
+ assert.equal(sent.length, 1);
120
+
121
+ await handlers.get("turn_start")?.[0]?.({}, ctx);
122
+ recordAskChoice(ctx, true);
123
+ recordAskChoice(ctx, false);
124
+ await handlers.get("turn_end")?.[0]?.({ message: { role: "assistant" } }, ctx);
125
+ assert.equal(sent.length, 1);
126
+ });
127
+ });
128
+
129
+ describe("ask_choice Auto-complete wiring", () => {
130
+ it("routes eligible questions through the run-scoped mode", () => {
131
+ const source = fs.readFileSync(path.join(process.cwd(), "tools", "ask-choice.ts"), "utf8");
132
+ assert.match(source, /isAutoCompleteEnabled/);
133
+ assert.match(source, /recordAskChoice\(ctx, true\)/);
134
+ assert.match(source, /autoComplete && selected\.startsWith\("Auto-complete"\)/);
135
+ const indexSource = fs.readFileSync(path.join(process.cwd(), "index.ts"), "utf8");
136
+ assert.match(indexSource, /plans-autocomplete-stop/);
137
+ assert.match(indexSource, /autoCompleteStatus\(ctx\)/);
138
+ assert.match(indexSource, /restoreAutoCompleteFromSession/);
139
+ const execSource = fs.readFileSync(path.join(process.cwd(), "src", "exec.ts"), "utf8");
140
+ assert.match(execSource, /customType === AUTOCOMPLETE_ENTRY/);
141
+ });
142
+ });
@@ -0,0 +1,74 @@
1
+ import * as assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ extractReadRecords,
5
+ formatReadRecord,
6
+ legalFirstKeptEntryIndex,
7
+ mergeCompactionDetails,
8
+ planIAwareCompaction,
9
+ currentIExceedsTrigger,
10
+ } from "../src/compaction.ts";
11
+
12
+ function textEntry(id: string, text: string, tokens = 100) {
13
+ return { id, type: "message", tokens, message: { role: "assistant", content: [{ type: "text", text }] } };
14
+ }
15
+
16
+ describe("I-aware compaction policy", () => {
17
+ it("keeps a legal current-I suffix and never starts at a tool result", () => {
18
+ const entries = [
19
+ textEntry("i1", "[I-001:current] completed"),
20
+ { id: "call", type: "message", tokens: 100, message: { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/repo/a.ts", offset: 4, limit: 2 } }] } },
21
+ { id: "result", type: "message", tokens: 100, message: { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: "private source output that must be bounded" }] } },
22
+ textEntry("i2", "[I-002:current] active"),
23
+ { id: "u", type: "message", tokens: 100, message: { role: "user", content: [{ type: "text", text: "latest question" }] } },
24
+ textEntry("a", "latest answer"),
25
+ ];
26
+ const plan = planIAwareCompaction({ entries, currentI: "I-002", knownIIds: ["I-001", "I-002"], contextWindow: 1000, tokensBefore: 600 });
27
+ assert.equal(plan.currentI, "I-002");
28
+ assert.equal(plan.slices.filter((slice) => slice.id !== null).length, 2);
29
+ assert.ok(plan.firstKeptEntryId);
30
+ assert.notEqual(plan.firstKeptEntryId, "result");
31
+ assert.ok(plan.summaryEntries.every((entry) => !plan.keptEntries.includes(entry)));
32
+ assert.equal(legalFirstKeptEntryIndex(entries, 2), 1);
33
+ });
34
+
35
+ it("starts a new current-I slice after a prior compaction snapshot", () => {
36
+ const entries = [
37
+ { id: "old-summary", type: "compaction", details: { currentI: "I-002" } },
38
+ textEntry("u", "new question", 100),
39
+ textEntry("a", "new answer", 100),
40
+ ];
41
+ const plan = planIAwareCompaction({ entries, currentI: "I-002", knownIIds: ["I-001", "I-002"], contextWindow: 1000, tokensBefore: 200 });
42
+ assert.equal(plan.currentStartIndex, 1);
43
+ assert.equal(plan.slices.at(-1)?.id, "I-002");
44
+ assert.equal(plan.slices.at(-1)?.current, true);
45
+ assert.equal(plan.slices.at(-1)?.entries[0]?.id, "u");
46
+ }); it("extracts bounded paired Read records and merges by path/range", () => {
47
+ const raw = "x".repeat(500);
48
+ const entries = [
49
+ { id: "call", type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "/repo/a.ts", offset: 7, limit: 3 } }] } },
50
+ { id: "result", type: "message", message: { role: "toolResult", toolCallId: "read-1", content: [{ type: "text", text: raw }] } },
51
+ ];
52
+ const records = extractReadRecords(entries);
53
+ assert.equal(records.length, 1);
54
+ assert.equal(records[0]?.range, "7-9");
55
+ assert.equal(records[0]?.formatted, formatReadRecord(records[0]!));
56
+ assert.match(records[0]?.formatted ?? "", /^Read: \/repo\/a\.ts line 7-9 Extracted information summary: /);
57
+ assert.ok((records[0]?.formatted.length ?? 0) < raw.length);
58
+ const merged = mergeCompactionDetails(
59
+ { readRecords: records },
60
+ { readRecords: [{ ...records[0]!, summary: "new extraction", formatted: "" }] },
61
+ );
62
+ assert.equal(merged.readRecords?.length, 1);
63
+ assert.equal(merged.readRecords?.[0]?.summary, "new extraction");
64
+ });
65
+
66
+ it("uses strict trigger and records a hard floor when the retained suffix cannot fit", () => {
67
+ assert.equal(currentIExceedsTrigger(200, 1000), false);
68
+ assert.equal(currentIExceedsTrigger(201, 1000), true);
69
+ const entries = [textEntry("i1", "[I-001:current] " + "work ".repeat(40), 900), textEntry("u", "latest", 200), textEntry("a", "answer", 200)];
70
+ const plan = planIAwareCompaction({ entries, currentI: "I-001", contextWindow: 1000, tokensBefore: 1300 });
71
+ assert.equal(plan.metrics.targetMet, false);
72
+ assert.ok(plan.metrics.hardFloorReason);
73
+ });
74
+ });