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/README.md +43 -17
- package/index.ts +34 -59
- package/package.json +1 -1
- package/references/pi-planning-workflow.md +6 -3
- package/references/state-and-config.md +1 -1
- package/src/autocomplete.ts +163 -0
- package/src/compaction.ts +502 -0
- package/src/exec.ts +557 -333
- package/src/plan.ts +37 -0
- package/src/query-hook.ts +82 -0
- package/src/refine-ui-helpers.ts +89 -0
- package/src/refine-ui-state.ts +78 -0
- package/src/refine-ui.ts +322 -0
- package/src/state.ts +9 -27
- package/src/subagent.ts +196 -69
- package/tests/autocomplete.test.ts +142 -0
- package/tests/compaction.test.ts +74 -0
- package/tests/exec.test.ts +153 -248
- package/tests/execute-plan.test.ts +65 -0
- package/tests/plan.test.ts +11 -1
- package/tests/plans.test.ts +6 -5
- package/tests/query-hook.test.ts +82 -0
- package/tests/refine-ui.test.ts +127 -0
- package/tests/state.test.ts +12 -15
- package/tests/subagent.test.ts +114 -0
- package/tools/ask-choice.ts +22 -0
- package/tools/execute-plan.ts +7 -39
- package/tools/plans.ts +1 -18
- package/tools/refine.ts +125 -71
- package/src/execution-panel.ts +0 -633
- package/tests/execution-panel.test.ts +0 -234
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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)
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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((
|
|
311
|
+
model: [...messages].reverse().find((message) => message.role === "assistant" && message.model)?.model,
|
|
187
312
|
stderr,
|
|
188
|
-
turns
|
|
313
|
+
turns,
|
|
189
314
|
};
|
|
190
315
|
} finally {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
});
|