@dyyz1993/pi-coding-agent 0.74.47 → 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.
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +9 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/session-manager.d.ts +28 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +89 -10
- package/dist/core/session-manager.js.map +1 -1
- package/dist/extensions/ask-tools/index.ts +45 -0
- package/dist/extensions/auto-session-title/index.ts +2 -0
- package/dist/extensions/compaction-manager/index.ts +68 -7
- package/dist/extensions/coordinator/index.ts +39 -0
- package/dist/extensions/hooks-engine/index.ts +3 -0
- package/dist/extensions/lsp/lsp/client/smart-file-tracker.ts +302 -0
- package/dist/extensions/lsp/lsp/utils/project-scanner.ts +101 -12
- package/dist/extensions/output-guard/index.ts +39 -0
- package/dist/extensions/preview/index.ts +23 -0
- package/dist/extensions/subagent-v2/extract-parent-todos.test.ts +146 -0
- package/dist/extensions/subagent-v2/index.ts +372 -15
- package/dist/extensions/todo-ext/index.ts +55 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +6 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +3 -0
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
+
// Strategy 2: shallow find (maxdepth 3, skip many common dirs)
|
|
31
82
|
try {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
{
|
|
@@ -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
|
+
});
|