@dyyz1993/pi-coding-agent 0.74.46 → 0.74.48

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.
Files changed (49) hide show
  1. package/dist/core/agent-session.d.ts.map +1 -1
  2. package/dist/core/agent-session.js +16 -0
  3. package/dist/core/agent-session.js.map +1 -1
  4. package/dist/core/session-manager.d.ts +28 -1
  5. package/dist/core/session-manager.d.ts.map +1 -1
  6. package/dist/core/session-manager.js +89 -10
  7. package/dist/core/session-manager.js.map +1 -1
  8. package/dist/extensions/ask-tools/index.ts +45 -0
  9. package/dist/extensions/auto-memory/__tests__/extract-result.test.ts +42 -0
  10. package/dist/extensions/auto-memory/__tests__/prefetch-history.test.ts +136 -0
  11. package/dist/extensions/auto-memory/__tests__/prompts.test.ts +29 -0
  12. package/dist/extensions/auto-memory/__tests__/skip-rules.test.ts +366 -0
  13. package/dist/extensions/auto-memory/contract.d.ts +16 -0
  14. package/dist/extensions/auto-memory/contract.d.ts.map +1 -1
  15. package/dist/extensions/auto-memory/contract.js.map +1 -1
  16. package/dist/extensions/auto-memory/contract.ts +16 -0
  17. package/dist/extensions/auto-memory/index.ts +134 -13
  18. package/dist/extensions/auto-memory/prompts.ts +10 -0
  19. package/dist/extensions/auto-memory/skip-rules.ts +2 -0
  20. package/dist/extensions/auto-session-title/index.ts +2 -0
  21. package/dist/extensions/bash-ext/index.ts +855 -845
  22. package/dist/extensions/claude-hooks-compat/index.ts +12 -7
  23. package/dist/extensions/compaction-manager/index.ts +68 -7
  24. package/dist/extensions/coordinator/handler.test.ts +388 -123
  25. package/dist/extensions/coordinator/handler.ts +78 -12
  26. package/dist/extensions/coordinator/index.ts +306 -198
  27. package/dist/extensions/coordinator/types.d.ts +16 -0
  28. package/dist/extensions/coordinator/types.d.ts.map +1 -1
  29. package/dist/extensions/coordinator/types.js.map +1 -1
  30. package/dist/extensions/coordinator/types.ts +57 -49
  31. package/dist/extensions/hooks-engine/index.ts +3 -0
  32. package/dist/extensions/lsp/lsp/client/smart-file-tracker.ts +302 -0
  33. package/dist/extensions/lsp/lsp/index.ts +15 -9
  34. package/dist/extensions/lsp/lsp/lsp-clangd-e2e.test.ts +229 -0
  35. package/dist/extensions/lsp/lsp/utils/project-scanner.ts +101 -12
  36. package/dist/extensions/message-bridge/index.ts +14 -11
  37. package/dist/extensions/output-guard/index.ts +39 -0
  38. package/dist/extensions/preview/index.ts +23 -0
  39. package/dist/extensions/session-supervisor/index.ts +14 -8
  40. package/dist/extensions/subagent-v2/extract-parent-todos.test.ts +146 -0
  41. package/dist/extensions/subagent-v2/index.ts +430 -57
  42. package/dist/extensions/todo-ext/index.ts +62 -3
  43. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  44. package/dist/modes/interactive/interactive-mode.js +6 -0
  45. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  46. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  47. package/dist/modes/rpc/rpc-mode.js +10 -0
  48. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  49. package/package.json +1 -1
@@ -5,8 +5,50 @@ import type { ResolvedLspServerConfig } from "../config/resolver.js";
5
5
 
6
6
  export interface ProjectScanResult {
7
7
  discoveredExtensions: Set<string>;
8
+ fileCount?: number;
8
9
  }
9
10
 
11
+ // Common source code file extensions to scan for
12
+ // This prevents scanning unnecessary files like .bak, .log, .bin, etc.
13
+ const COMMON_SOURCE_EXTENSIONS = new Set([
14
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
15
+ ".vue", ".svelte", ".jsx",
16
+ ".py", ".pyi",
17
+ ".rs",
18
+ ".go",
19
+ ".java", ".kt", ".kts",
20
+ ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hxx",
21
+ ".cs", ".vb",
22
+ ".php",
23
+ ".rb",
24
+ ".swift", ".m", ".mm",
25
+ ".dart",
26
+ ".lua",
27
+ ".sh", ".bash", ".zsh",
28
+ ".sql",
29
+ ".graphql", ".gql",
30
+ ".yaml", ".yml",
31
+ ".toml",
32
+ ".json",
33
+ ".md",
34
+ ".xml", ".html", ".htm", ".css", ".scss", ".less", ".sass",
35
+ ".txt",
36
+ ]);
37
+
38
+ // Directories to exclude from scanning (in addition to .git, node_modules)
39
+ const EXCLUDED_DIRS = [
40
+ "node_modules", ".git", "target", "dist", "build", ".pi",
41
+ ".next", ".nuxt", ".output", ".vercel",
42
+ "venv", "env", ".venv", "envs", ".envs", "__pycache__",
43
+ ".vscode", ".idea",
44
+ "coverage", ".nyc_output",
45
+ ".cache", "tmp", "temp",
46
+ ".DS_Store", "Thumbs.db",
47
+ ];
48
+
49
+ // Maximum files to scan before stopping
50
+ const MAX_FILES_TO_SCAN = 5000;
51
+
10
52
  /**
11
53
  * Scan the project for file types present on disk.
12
54
  * Uses `git ls-files` when available (fast, respects .gitignore),
@@ -14,38 +56,76 @@ export interface ProjectScanResult {
14
56
  */
15
57
  export function scanProjectFileTypes(cwd: string): ProjectScanResult {
16
58
  const extensions = new Set<string>();
59
+ let fileCount = 0;
17
60
 
18
61
  // Strategy 1: git ls-files (fast, respects gitignore)
19
62
  const gitFiles = tryGitLsFiles(cwd);
20
63
  if (gitFiles.length > 0) {
21
64
  for (const file of gitFiles) {
65
+ fileCount++;
66
+ if (fileCount > MAX_FILES_TO_SCAN) {
67
+ console.warn(`[lsp] Stopped scan after ${MAX_FILES_TO_SCAN} files (too many files)`);
68
+ break;
69
+ }
70
+
22
71
  const ext = extname(file).toLowerCase();
23
- if (ext) {
72
+ // Only collect common source code extensions
73
+ if (ext && COMMON_SOURCE_EXTENSIONS.has(ext)) {
24
74
  extensions.add(ext);
25
75
  }
26
76
  }
27
- return { discoveredExtensions: extensions };
77
+ console.log(`[lsp] Project scan found ${extensions.size} file types from ${fileCount} files (git mode)`);
78
+ return { discoveredExtensions: extensions, fileCount };
28
79
  }
29
80
 
30
- // Strategy 2: shallow find (maxdepth 3, skip node_modules etc.)
81
+ // Strategy 2: shallow find (maxdepth 3, skip many common dirs)
31
82
  try {
32
- const output = execSync(
33
- 'find . -maxdepth 3 -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/target/*" -not -path "*/dist/*" -not -path "*/.pi/*" 2>/dev/null | head -2000',
34
- { cwd, timeout: 3000, encoding: "utf8" },
35
- );
36
- for (const line of output.split("\n")) {
83
+ const excludeArgs = EXCLUDED_DIRS.map((dir) => `-not -path "*/${dir}/*"`).join(" ");
84
+ const command = `find . -maxdepth 3 -type f ${excludeArgs} 2>/dev/null | head -${MAX_FILES_TO_SCAN}`;
85
+ const output = execSync(command, {
86
+ cwd,
87
+ timeout: 3000,
88
+ encoding: "utf8",
89
+ });
90
+
91
+ const lines = output.split("\n").filter(Boolean);
92
+ fileCount = 0;
93
+
94
+ for (const line of lines) {
37
95
  const trimmed = line.trim();
38
96
  if (!trimmed) continue;
97
+
98
+ fileCount++;
99
+
100
+ // Check memory usage periodically
101
+ if (fileCount % 1000 === 0) {
102
+ const memUsage = process.memoryUsage();
103
+ const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
104
+ const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
105
+
106
+ // If we're using >3GB of heap, stop scanning
107
+ if (heapUsedMB > 3000) {
108
+ console.warn(`[lsp] Stopping scan due to high memory usage (${heapUsedMB}MB heap used)`);
109
+ break;
110
+ }
111
+ }
112
+
39
113
  const ext = extname(trimmed).toLowerCase();
40
- if (ext) {
114
+ // Only collect common source code extensions
115
+ if (ext && COMMON_SOURCE_EXTENSIONS.has(ext)) {
41
116
  extensions.add(ext);
42
117
  }
43
118
  }
44
- } catch {
119
+
120
+ console.log(`[lsp] Project scan found ${extensions.size} file types from ${fileCount} files (find mode)`);
121
+ } catch (error) {
122
+ if (error instanceof Error) {
123
+ console.warn(`[lsp] Project scan failed: ${error.message}`);
124
+ }
45
125
  // If scan fails, return empty — will fall back to starting all servers
46
126
  }
47
127
 
48
- return { discoveredExtensions: extensions };
128
+ return { discoveredExtensions: extensions, fileCount };
49
129
  }
50
130
 
51
131
  function tryGitLsFiles(cwd: string): string[] {
@@ -76,10 +156,17 @@ export function filterServersByProject(
76
156
  servers: ResolvedLspServerConfig[],
77
157
  scanResult: ProjectScanResult,
78
158
  ): ResolvedLspServerConfig[] {
79
- const { discoveredExtensions } = scanResult;
159
+ const { discoveredExtensions, fileCount } = scanResult;
80
160
 
81
161
  // Safe fallback: if scan found nothing, start everything
82
162
  if (discoveredExtensions.size === 0) {
163
+ console.log(`[lsp] No file types discovered, starting all ${servers.length} servers`);
164
+ return servers;
165
+ }
166
+
167
+ // If we scanned very few files (<10), might be an empty project - start all servers
168
+ if (fileCount !== undefined && fileCount < 10) {
169
+ console.log(`[lsp] Only ${fileCount} files scanned, starting all ${servers.length} servers`);
83
170
  return servers;
84
171
  }
85
172
 
@@ -98,5 +185,7 @@ export function filterServersByProject(
98
185
  }
99
186
  }
100
187
 
188
+ console.log(`[lsp] Filtered to ${filtered.length}/${servers.length} servers based on project files`);
189
+
101
190
  return filtered;
102
191
  }
@@ -139,6 +139,7 @@ export default function messageBridgeExtension(pi: any) {
139
139
 
140
140
  pi.on("ui", async (event: any, ctx: any) => {
141
141
  if (event.method === "notify") {
142
+ if (event.message == null) return undefined;
142
143
  pushAndWait(event.message, sessionId).catch((err) => console.debug("[message-bridge] notify push failed:", err instanceof Error ? err.message : err));
143
144
  return undefined;
144
145
  }
@@ -148,7 +149,7 @@ export default function messageBridgeExtension(pi: any) {
148
149
  pushAndWait(question, sessionId)
149
150
  .then((answer) => {
150
151
  const confirmed = parseConfirmAnswer(answer);
151
- ctx.respondUI(event.id, { action: "responded", confirmed });
152
+ try { ctx.respondUI(event.id, { action: "responded", confirmed }); } catch (e) { if (!/stale/i.test(e instanceof Error ? e.message : "")) throw e; }
152
153
  })
153
154
  .catch((err) => console.debug("[message-bridge] confirm push failed:", err instanceof Error ? err.message : err));
154
155
  return undefined;
@@ -160,13 +161,15 @@ export default function messageBridgeExtension(pi: any) {
160
161
  const question = buildSelectQuestion(event.title, options, multiple);
161
162
  pushAndWait(question, sessionId)
162
163
  .then((answer) => {
163
- if (multiple) {
164
- const values = parseMultiSelectAnswer(answer, options);
165
- ctx.respondUI(event.id, { action: "responded", value: values });
166
- } else {
167
- const value = parseSelectAnswer(answer);
168
- ctx.respondUI(event.id, { action: "responded", value });
169
- }
164
+ try {
165
+ if (multiple) {
166
+ const values = parseMultiSelectAnswer(answer, options);
167
+ ctx.respondUI(event.id, { action: "responded", value: values });
168
+ } else {
169
+ const value = parseSelectAnswer(answer);
170
+ ctx.respondUI(event.id, { action: "responded", value });
171
+ }
172
+ } catch (e) { if (!/stale/i.test(e instanceof Error ? e.message : "")) throw e; }
170
173
  })
171
174
  .catch((err) => console.debug("[message-bridge] select push failed:", err instanceof Error ? err.message : err));
172
175
  return undefined;
@@ -178,7 +181,7 @@ export default function messageBridgeExtension(pi: any) {
178
181
  : event.title;
179
182
  pushAndWait(question, sessionId)
180
183
  .then((answer) => {
181
- ctx.respondUI(event.id, { action: "responded", value: answer });
184
+ try { ctx.respondUI(event.id, { action: "responded", value: answer }); } catch (e) { if (!/stale/i.test(e instanceof Error ? e.message : "")) throw e; }
182
185
  })
183
186
  .catch((err) => console.debug("[message-bridge] input push failed:", err instanceof Error ? err.message : err));
184
187
  return undefined;
@@ -190,7 +193,7 @@ export default function messageBridgeExtension(pi: any) {
190
193
  : event.title;
191
194
  pushAndWait(question, sessionId)
192
195
  .then((answer) => {
193
- ctx.respondUI(event.id, { action: "responded", value: answer });
196
+ try { ctx.respondUI(event.id, { action: "responded", value: answer }); } catch (e) { if (!/stale/i.test(e instanceof Error ? e.message : "")) throw e; }
194
197
  })
195
198
  .catch((err) => console.debug("[message-bridge] editor push failed:", err instanceof Error ? err.message : err));
196
199
  return undefined;
@@ -214,7 +217,7 @@ export default function messageBridgeExtension(pi: any) {
214
217
  .then((id) => pullAnswer(id))
215
218
  .then((answer) => {
216
219
  if (answer?.trim()) {
217
- pi.sendUserMessage(answer.trim());
220
+ try { pi.sendUserMessage(answer.trim()); } catch (e) { if (!/stale/i.test(e instanceof Error ? e.message : "")) throw e; }
218
221
  }
219
222
  })
220
223
  .catch((err) => console.debug("[message-bridge] agent_end push failed:", err instanceof Error ? err.message : err));
@@ -217,6 +217,9 @@ function saveFullOutput(content: string, ctx: ExtensionContext): string | undefi
217
217
  // ============================================================================
218
218
 
219
219
  export default function outputGuard(pi: ExtensionAPI) {
220
+ pi.setName("output-guard");
221
+ let truncatedCount = 0;
222
+ let limitAdjustedCount = 0;
220
223
  // ------------------------------------------------------------------
221
224
  // 1. Global truncation fallback via tool_result hook
222
225
  //
@@ -255,6 +258,7 @@ export default function outputGuard(pi: ExtensionAPI) {
255
258
 
256
259
  // Truncate
257
260
  const result = truncateOutput(fullText, config, ctx);
261
+ truncatedCount++;
258
262
 
259
263
  let finalContent = result.content;
260
264
  if (result.truncated) {
@@ -262,6 +266,21 @@ export default function outputGuard(pi: ExtensionAPI) {
262
266
  finalContent = finalContent + "\n\n" + notice;
263
267
  }
264
268
 
269
+ console.debug(
270
+ `[output-guard] truncated tool "${event.toolName}": ${result.totalLines} lines / ${result.totalBytes} bytes → ${result.outputLines} lines / ${result.outputBytes} bytes (truncatedBy: ${result.truncatedBy ?? "none"}, path: ${result.fullOutputPath ?? "N/A"})`,
271
+ );
272
+ pi.appendEntry("output_guard_truncate", {
273
+ toolName: event.toolName,
274
+ totalLines: result.totalLines,
275
+ totalBytes: result.totalBytes,
276
+ outputLines: result.outputLines,
277
+ outputBytes: result.outputBytes,
278
+ truncated: result.truncated,
279
+ truncatedBy: result.truncatedBy,
280
+ fullOutputPath: result.fullOutputPath,
281
+ truncatedCount,
282
+ });
283
+
265
284
  return {
266
285
  content: [{ type: "text" as const, text: finalContent }],
267
286
  };
@@ -281,7 +300,9 @@ export default function outputGuard(pi: ExtensionAPI) {
281
300
  if (event.toolName === "find") {
282
301
  const input = event.input as { limit?: number };
283
302
  if (input.limit === undefined || input.limit > config.findLimit) {
303
+ console.debug(`[output-guard] capped find limit: ${input.limit ?? "unlimited"} → ${config.findLimit}`);
284
304
  input.limit = config.findLimit;
305
+ limitAdjustedCount++;
285
306
  }
286
307
  }
287
308
 
@@ -289,7 +310,9 @@ export default function outputGuard(pi: ExtensionAPI) {
289
310
  if (event.toolName === "ls") {
290
311
  const input = event.input as { limit?: number };
291
312
  if (input.limit === undefined || input.limit > config.lsLimit) {
313
+ console.debug(`[output-guard] capped ls limit: ${input.limit ?? "unlimited"} → ${config.lsLimit}`);
292
314
  input.limit = config.lsLimit;
315
+ limitAdjustedCount++;
293
316
  }
294
317
  }
295
318
  });
@@ -335,11 +358,18 @@ export default function outputGuard(pi: ExtensionAPI) {
335
358
  try {
336
359
  const buffer = await fs.readFile(absolutePath);
337
360
 
361
+ console.debug(`[output-guard] pdf_read: ${args.path} (${buffer.length} bytes, pages: ${args.maxPages ?? "all"})`);
362
+
338
363
  // Dynamic import of pdf-parse (optional dependency)
339
364
  let pdfParse: typeof import("pdf-parse") | undefined;
340
365
  try {
341
366
  pdfParse = (await import("pdf-parse")).default;
342
367
  } catch {
368
+ console.debug("[output-guard] pdf_read failed: pdf-parse not installed");
369
+ pi.appendEntry("output_guard_pdf_error", {
370
+ path: args.path,
371
+ error: "pdf-parse not installed",
372
+ });
343
373
  return {
344
374
  content: [
345
375
  {
@@ -354,6 +384,15 @@ export default function outputGuard(pi: ExtensionAPI) {
354
384
  const data = await pdfParse(buffer);
355
385
  let text = data.text;
356
386
 
387
+ console.debug(`[output-guard] pdf_read success: ${args.path} (${data.numpages} pages, ${text.length} chars extracted)`);
388
+ pi.appendEntry("output_guard_pdf_read", {
389
+ path: args.path,
390
+ pages: data.numpages,
391
+ chars: text.length,
392
+ title: data.info?.Title ?? null,
393
+ author: data.info?.Author ?? null,
394
+ });
395
+
357
396
  // Add metadata header
358
397
  const header = [
359
398
  `PDF: ${args.path}`,
@@ -101,6 +101,8 @@ const PreviewParams = Type.Object({
101
101
  title: Type.Optional(Type.String({ description: "Optional display title for the card" })),
102
102
  });
103
103
 
104
+ let previewId = 0;
105
+
104
106
  export default function (pi: ExtensionAPI) {
105
107
  pi.registerTool({
106
108
  name: "preview",
@@ -118,8 +120,11 @@ export default function (pi: ExtensionAPI) {
118
120
  ctx?: ExtensionContext,
119
121
  ): Promise<AgentToolResult<PreviewDetails>> {
120
122
  const cwd = ctx?.cwd ?? process.cwd();
123
+ previewId++;
121
124
 
122
125
  if (!params.source?.trim()) {
126
+ console.debug(`[preview] #${previewId} error: source is required`);
127
+ pi.appendEntry("preview", { id: previewId, status: "error", error: "source required" });
123
128
  return {
124
129
  content: [{ type: "text", text: "Error: source is required" }],
125
130
  details: { source: "", resourceType: "text", status: "error", error: "source required" },
@@ -138,6 +143,8 @@ export default function (pi: ExtensionAPI) {
138
143
  const reachable = await checkReachable(parsed.hostname, port);
139
144
  if (!reachable) {
140
145
  const msg = `Preview 失败:${parsed.host} 未在局域网开放,服务可能只监听 127.0.0.1。请将服务绑定到 0.0.0.0 后重试。`;
146
+ console.debug(`[preview] #${previewId} error: local address "${parsed.host}:${parsed.port || 80}" not reachable`);
147
+ pi.appendEntry("preview", { id: previewId, source: params.source, status: "error", error: "local address not reachable", host: parsed.host, port: parseInt(parsed.port || "80", 10) });
141
148
  return {
142
149
  content: [{ type: "text", text: msg }],
143
150
  details: {
@@ -156,6 +163,8 @@ export default function (pi: ExtensionAPI) {
156
163
  }
157
164
  }
158
165
 
166
+ console.debug(`[preview] #${previewId} url: ${params.source}`);
167
+ pi.appendEntry("preview", { id: previewId, source: params.source, type: "url", status: "ok", title: params.title });
159
168
  return {
160
169
  content: [{ type: "text", text: `Preview: ${params.source} (url)` }],
161
170
  details: {
@@ -169,6 +178,8 @@ export default function (pi: ExtensionAPI) {
169
178
  }
170
179
 
171
180
  if (!absolutePath || !existsSync(absolutePath)) {
181
+ console.debug(`[preview] #${previewId} not_found: ${params.source}`);
182
+ pi.appendEntry("preview", { id: previewId, source: params.source, type: resourceType, status: "not_found" });
172
183
  return {
173
184
  content: [{ type: "text", text: `Preview: ${params.source} not found` }],
174
185
  details: {
@@ -184,6 +195,8 @@ export default function (pi: ExtensionAPI) {
184
195
 
185
196
  const stat = statSync(absolutePath);
186
197
  if (stat.isDirectory()) {
198
+ console.debug(`[preview] #${previewId} error: ${params.source} is a directory`);
199
+ pi.appendEntry("preview", { id: previewId, source: params.source, type: resourceType, status: "error", error: "is a directory" });
187
200
  return {
188
201
  content: [{ type: "text", text: `Preview: ${params.source} is a directory` }],
189
202
  details: {
@@ -204,6 +217,16 @@ export default function (pi: ExtensionAPI) {
204
217
  ? `${(stat.size / 1024).toFixed(1)}KB`
205
218
  : `${stat.size}B`;
206
219
 
220
+ console.debug(`[preview] #${previewId} ok: ${params.source} (${resourceType}, ${sizeStr})`);
221
+ pi.appendEntry("preview", {
222
+ id: previewId,
223
+ source: params.source,
224
+ type: resourceType,
225
+ mimeType,
226
+ size: stat.size,
227
+ status: "ok",
228
+ title: params.title,
229
+ });
207
230
  return {
208
231
  content: [
209
232
  {
@@ -369,14 +369,20 @@ export default function sessionSupervisorExtension(pi: ExtensionAPI) {
369
369
 
370
370
  currentState = "continuing";
371
371
  emitStatusChanged();
372
- pi.sendMessage(
373
- {
374
- customType: "supervisor_continue",
375
- content: continueMessage,
376
- display: true,
377
- },
378
- { triggerTurn: true },
379
- );
372
+ try {
373
+ pi.sendMessage(
374
+ {
375
+ customType: "supervisor_continue",
376
+ content: continueMessage,
377
+ display: true,
378
+ },
379
+ { triggerTurn: true },
380
+ );
381
+ } catch (err) {
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ if (/stale|abort/i.test(msg)) return;
384
+ throw err;
385
+ }
380
386
  });
381
387
  }
382
388
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Tests for extractParentTodos — extracts parent session's todo list
3
+ * from session history entries for read-only reference in sub-agents.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { extractParentTodos } from "./index.js";
7
+
8
+ interface CustomEntry {
9
+ type: "custom";
10
+ customType: string;
11
+ data?: { todos: unknown[]; nextId?: number };
12
+ }
13
+
14
+ interface MessageEntry {
15
+ type: "message";
16
+ message: {
17
+ role: string;
18
+ toolName?: string;
19
+ details?: { todos: unknown[]; nextId?: number };
20
+ };
21
+ }
22
+
23
+ type Entry = CustomEntry | MessageEntry;
24
+
25
+ function customEntry(customType: string, todos: unknown[], nextId = 1): CustomEntry {
26
+ return { type: "custom", customType, data: { todos, nextId } };
27
+ }
28
+
29
+ function toolResultEntry(details: { todos: unknown[]; nextId?: number }): MessageEntry {
30
+ return {
31
+ type: "message",
32
+ message: { role: "toolResult", toolName: "todo", details },
33
+ };
34
+ }
35
+
36
+ function userMessageEntry(): MessageEntry {
37
+ return { type: "message", message: { role: "user" } };
38
+ }
39
+
40
+ function assistantMessageEntry(): MessageEntry {
41
+ return { type: "message", message: { role: "assistant" } };
42
+ }
43
+
44
+ describe("extractParentTodos", () => {
45
+ it("returns empty array when branch is empty", () => {
46
+ expect(extractParentTodos([])).toEqual([]);
47
+ });
48
+
49
+ it("returns empty array when branch has no todo entries", () => {
50
+ const branch: Entry[] = [userMessageEntry(), assistantMessageEntry()];
51
+ expect(extractParentTodos(branch)).toEqual([]);
52
+ });
53
+
54
+ it("returns active todos from custom entry", () => {
55
+ const todos = [
56
+ { id: 1, text: "Fix login", done: false },
57
+ { id: 2, text: "Add tests", done: false },
58
+ ];
59
+ const branch: Entry[] = [customEntry("todo", todos)];
60
+ const result = extractParentTodos(branch);
61
+ expect(result).toHaveLength(2);
62
+ expect(result[0]).toEqual({ id: 1, text: "Fix login", priority: undefined, done: false });
63
+ expect(result[1]).toEqual({ id: 2, text: "Add tests", priority: undefined, done: false });
64
+ });
65
+
66
+ it("filters out done todos", () => {
67
+ const todos = [
68
+ { id: 1, text: "Done task", done: true },
69
+ { id: 2, text: "Active task", done: false },
70
+ ];
71
+ const branch: Entry[] = [customEntry("todo", todos)];
72
+ const result = extractParentTodos(branch);
73
+ expect(result).toHaveLength(1);
74
+ expect(result[0].id).toBe(2);
75
+ });
76
+
77
+ it("filters out deleted todos", () => {
78
+ const todos = [
79
+ { id: 1, text: "Deleted task", done: false, deleted: true },
80
+ { id: 2, text: "Active task", done: false },
81
+ ];
82
+ const branch: Entry[] = [customEntry("todo", todos)];
83
+ const result = extractParentTodos(branch);
84
+ expect(result).toHaveLength(1);
85
+ expect(result[0].id).toBe(2);
86
+ });
87
+
88
+ it("returns the latest todo list from toolResult messages (overwrites previous)", () => {
89
+ const earlyTodos = [{ id: 1, text: "Old task", done: false }];
90
+ const latestTodos = [
91
+ { id: 1, text: "Old task", done: true },
92
+ { id: 2, text: "New task", done: false },
93
+ ];
94
+ const branch: Entry[] = [customEntry("todo", earlyTodos), toolResultEntry({ todos: latestTodos, nextId: 3 })];
95
+ const result = extractParentTodos(branch);
96
+ expect(result).toHaveLength(1);
97
+ expect(result[0].id).toBe(2);
98
+ expect(result[0].text).toBe("New task");
99
+ });
100
+
101
+ it("preserves priority field", () => {
102
+ const todos = [
103
+ { id: 1, text: "High priority", done: false, priority: "high" },
104
+ { id: 2, text: "Low priority", done: false, priority: "low" },
105
+ { id: 3, text: "Medium priority", done: false },
106
+ ];
107
+ const branch: Entry[] = [customEntry("todo", todos)];
108
+ const result = extractParentTodos(branch);
109
+ expect(result[0].priority).toBe("high");
110
+ expect(result[1].priority).toBe("low");
111
+ expect(result[2].priority).toBeUndefined();
112
+ });
113
+
114
+ it("ignores non-todo entries interspersed", () => {
115
+ const todos = [{ id: 1, text: "The only task", done: false }];
116
+ const branch: Entry[] = [
117
+ userMessageEntry(),
118
+ assistantMessageEntry(),
119
+ customEntry("some_other_thing", [{ unrelated: true }]),
120
+ customEntry("todo", todos),
121
+ userMessageEntry(),
122
+ ];
123
+ const result = extractParentTodos(branch);
124
+ expect(result).toHaveLength(1);
125
+ expect(result[0].text).toBe("The only task");
126
+ });
127
+
128
+ it("handles toolResult with no matching details gracefully", () => {
129
+ const branch: Entry[] = [
130
+ {
131
+ type: "message",
132
+ message: { role: "toolResult", toolName: "bash", details: { exitCode: 0 } },
133
+ },
134
+ ];
135
+ expect(extractParentTodos(branch)).toEqual([]);
136
+ });
137
+
138
+ it("returns empty array when all todos are done", () => {
139
+ const todos = [
140
+ { id: 1, text: "Done A", done: true },
141
+ { id: 2, text: "Done B", done: true, deleted: true },
142
+ ];
143
+ const branch: Entry[] = [customEntry("todo", todos)];
144
+ expect(extractParentTodos(branch)).toEqual([]);
145
+ });
146
+ });