@pi-archimedes/subagent 1.8.2 → 1.9.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.
@@ -0,0 +1,483 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { saveAgent } from "./agent-manager.js";
6
+ import { discoverAgentsAll } from "./agents.js";
7
+ import { writeLocalModel } from "./local-config.js";
8
+
9
+ // Mock writeLocalModel to always throw (simulating a JSON store write
10
+ // failure). deleteLocalModel is a no-op since it's never reached in
11
+ // these tests. readLocalConfig / setLocalConfig / getLocalConfigPath
12
+ // use their REAL implementations (via importOriginal) so the saveAgent
13
+ // JSON backup/restore logic can be exercised end-to-end.
14
+ vi.mock("./local-config.js", async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import("./local-config.js")>();
16
+ return {
17
+ ...actual,
18
+ writeLocalModel: vi.fn(() => {
19
+ throw new Error("JSON write failed");
20
+ }),
21
+ deleteLocalModel: vi.fn(),
22
+ };
23
+ });
24
+
25
+ // Partial mock of ./agents.js: keep all original exports but replace
26
+ // discoverAgentsAll with a vi.fn() so individual tests can control its
27
+ // behavior (e.g. throwing to simulate a re-discovery failure). Existing
28
+ // tests never reach discoverAgentsAll because they fail earlier; the
29
+ // default vi.fn() returns undefined which is fine for those paths.
30
+ vi.mock("./agents.js", async (importOriginal) => ({
31
+ ...((await importOriginal()) as Record<string, unknown>),
32
+ discoverAgentsAll: vi.fn(),
33
+ }));
34
+
35
+ // Redirect getAgentDir() to a temp directory via PI_CODING_AGENT_DIR.
36
+ // This must happen before any function calls so readLocalConfig()
37
+ // resolves agents.local.json to our sandbox directory.
38
+ const testDir = join(tmpdir(), "pi-test-save-agent");
39
+ process.env.PI_CODING_AGENT_DIR = testDir;
40
+
41
+ describe("saveAgent JSON rename cleanup ordering", () => {
42
+ beforeEach(() => {
43
+ mkdirSync(testDir, { recursive: true });
44
+ });
45
+
46
+ afterEach(() => {
47
+ rmSync(testDir, { recursive: true, force: true });
48
+ });
49
+
50
+ it("does not delete old JSON rename entry when .md write fails", () => {
51
+ // Pre-populate agents.local.json with the OLD name's model override
52
+ writeFileSync(
53
+ join(testDir, "agents.local.json"),
54
+ JSON.stringify({ oldcodex: { model: "claude-3.7" } }),
55
+ "utf-8",
56
+ );
57
+
58
+ // Make the .md write fail by creating codex.md as a directory.
59
+ // writeFileSync will throw EISDIR when writing to a directory path.
60
+ mkdirSync(join(testDir, "codex.md"), { recursive: true });
61
+
62
+ const state = {
63
+ editAgent: {
64
+ name: "codex",
65
+ description: "test agent",
66
+ systemPrompt: "hello world",
67
+ source: "user",
68
+ filePath: join(testDir, "oldcodex.md"),
69
+ model: "gpt-4o",
70
+ },
71
+ editOriginal: {
72
+ name: "oldcodex",
73
+ description: "test agent",
74
+ systemPrompt: "hello world",
75
+ source: "user",
76
+ filePath: join(testDir, "oldcodex.md"),
77
+ },
78
+ agents: [],
79
+ globalDir: null,
80
+ userDir: testDir,
81
+ projectDir: null,
82
+ editError: null,
83
+ editDirty: false,
84
+ };
85
+
86
+ saveAgent(state as any, () => {});
87
+
88
+ // The .md write failed, so the old JSON rename entry should NOT have been
89
+ // deleted. If deleteLocalModel(originalName) ran before the .md write
90
+ // (the bug), the old entry would be gone here.
91
+ const config = JSON.parse(
92
+ readFileSync(join(testDir, "agents.local.json"), "utf-8"),
93
+ );
94
+ expect(config.oldcodex).toEqual({ model: "claude-3.7" });
95
+ expect(state.editError).toBeTruthy();
96
+ });
97
+ });
98
+
99
+ describe("saveAgent rollback model to .md", () => {
100
+ beforeEach(() => {
101
+ mkdirSync(testDir, { recursive: true });
102
+ });
103
+
104
+ afterEach(() => {
105
+ rmSync(testDir, { recursive: true, force: true });
106
+ });
107
+
108
+ it("restore model to .md when JSON write fails", () => {
109
+ // writeLocalModel is mocked (vi.mock at top of file) to always throw.
110
+ // The .md write itself succeeds (real fs.writeFileSync), then the
111
+ // JSON store write fails, triggering the catch-block rollback.
112
+
113
+ const state = {
114
+ editAgent: {
115
+ name: "codex",
116
+ description: "test agent",
117
+ systemPrompt: "hello world",
118
+ source: "user",
119
+ filePath: join(testDir, "codex.md"),
120
+ model: "openai/gpt-4o",
121
+ },
122
+ editOriginal: {
123
+ name: "codex",
124
+ description: "test agent",
125
+ systemPrompt: "hello world",
126
+ source: "user",
127
+ filePath: join(testDir, "codex.md"),
128
+ },
129
+ agents: [],
130
+ globalDir: null,
131
+ userDir: testDir,
132
+ projectDir: null,
133
+ editError: null,
134
+ editDirty: false,
135
+ };
136
+
137
+ saveAgent(state as any, () => {});
138
+
139
+ // The .md file should still contain the model field (rollback).
140
+ const mdContent = readFileSync(join(testDir, "codex.md"), "utf-8");
141
+ expect(mdContent).toContain("model: openai/gpt-4o");
142
+
143
+ // editError should be set.
144
+ expect(state.editError).toBeTruthy();
145
+
146
+ // The live edit in-memory object should still have the model (not deleted).
147
+ expect(state.editAgent!.model).toBe("openai/gpt-4o");
148
+ });
149
+
150
+ it("restores original .md content when JSON write fails", () => {
151
+ // writeLocalModel is mocked (vi.mock at top of file) to always throw,
152
+ // so the .md write succeeds but the JSON store write fails, triggering
153
+ // the catch-block restore-original path.
154
+ // Pre-create the .md file with original content (including an old model
155
+ // override in frontmatter) so saveAgent can capture and restore it.
156
+ const originalMd = [
157
+ "---",
158
+ "name: codex",
159
+ "description: original description",
160
+ "model: old-model",
161
+ "---",
162
+ "",
163
+ "original prompt",
164
+ "",
165
+ ].join("\n");
166
+ writeFileSync(join(testDir, "codex.md"), originalMd, "utf-8");
167
+
168
+ const state = {
169
+ editAgent: {
170
+ name: "codex",
171
+ description: "new description",
172
+ systemPrompt: "new prompt",
173
+ source: "user",
174
+ filePath: join(testDir, "codex.md"),
175
+ model: "new-model",
176
+ },
177
+ editOriginal: {
178
+ name: "codex",
179
+ description: "original description",
180
+ systemPrompt: "original prompt",
181
+ source: "user",
182
+ filePath: join(testDir, "codex.md"),
183
+ },
184
+ agents: [],
185
+ globalDir: null,
186
+ userDir: testDir,
187
+ projectDir: null,
188
+ editError: null,
189
+ editDirty: false,
190
+ };
191
+
192
+ saveAgent(state as any, () => {});
193
+
194
+ // The .md file should have been restored to the original content
195
+ // verbatim (including the old model), NOT left with the new edit.
196
+ const mdContent = readFileSync(join(testDir, "codex.md"), "utf-8");
197
+ expect(mdContent).toContain("model: old-model");
198
+ expect(mdContent).not.toContain("new description");
199
+
200
+ // editError should be set.
201
+ expect(state.editError).toBeTruthy();
202
+ });
203
+ });
204
+
205
+ describe("saveAgent retry safety when re-discovery fails", () => {
206
+ beforeEach(() => {
207
+ mkdirSync(testDir, { recursive: true });
208
+ });
209
+
210
+ afterEach(() => {
211
+ rmSync(testDir, { recursive: true, force: true });
212
+ });
213
+
214
+ it("retains model for retry when re-discovery fails", () => {
215
+ // Make writeLocalModel succeed for this call only (default mock throws).
216
+ // This lets execution proceed past the JSON write to discoverAgentsAll.
217
+ vi.mocked(writeLocalModel).mockImplementationOnce(() => {});
218
+
219
+ // Make discoverAgentsAll throw after the save completes.
220
+ vi.mocked(discoverAgentsAll).mockImplementationOnce(() => {
221
+ throw new Error("discovery failed");
222
+ });
223
+
224
+ const state = {
225
+ editAgent: {
226
+ name: "codex",
227
+ description: "test agent",
228
+ systemPrompt: "hello world",
229
+ source: "user",
230
+ filePath: join(testDir, "codex.md"),
231
+ model: "openai/gpt-4o",
232
+ },
233
+ editOriginal: {
234
+ name: "codex",
235
+ description: "test agent",
236
+ systemPrompt: "hello world",
237
+ source: "user",
238
+ filePath: join(testDir, "codex.md"),
239
+ },
240
+ agents: [],
241
+ globalDir: null,
242
+ userDir: testDir,
243
+ projectDir: null,
244
+ editError: null,
245
+ editDirty: false,
246
+ };
247
+
248
+ saveAgent(state as any, () => {});
249
+
250
+ // The live edit object should still have the model — delete agent.model
251
+ // only runs after requestRender() which never executed because
252
+ // discoverAgentsAll threw first.
253
+ expect(state.editAgent!.model).toBe("openai/gpt-4o");
254
+
255
+ // editError should contain the discovery failure message.
256
+ expect(state.editError).toContain("discovery failed");
257
+
258
+ // The .md file should have the model restored by the catch block
259
+ // rollback (serializeAgent({ ...agent, model })).
260
+ const mdContent = readFileSync(join(testDir, "codex.md"), "utf-8");
261
+ expect(mdContent).toContain("model: openai/gpt-4o");
262
+ });
263
+
264
+ it("does not delete renamed .md when re-discovery fails after old file removed", () => {
265
+ // Allow writeLocalModel to succeed, then make discoverAgentsAll throw
266
+ // after the rename unlinkSync has already run (oldPath is gone).
267
+ vi.mocked(writeLocalModel).mockImplementationOnce(() => {});
268
+ vi.mocked(discoverAgentsAll).mockImplementationOnce(() => {
269
+ throw new Error("discovery failed");
270
+ });
271
+
272
+ // Pre-create the old .md file (the original agent at oldPath).
273
+ writeFileSync(
274
+ join(testDir, "oldcodex.md"),
275
+ "original rename content",
276
+ "utf-8",
277
+ );
278
+
279
+ const state = {
280
+ editAgent: {
281
+ name: "codex",
282
+ description: "new description",
283
+ systemPrompt: "new prompt",
284
+ source: "user",
285
+ filePath: join(testDir, "oldcodex.md"), // oldPath
286
+ model: "openai/gpt-4o",
287
+ },
288
+ editOriginal: {
289
+ name: "oldcodex",
290
+ description: "original description",
291
+ systemPrompt: "original prompt",
292
+ source: "user",
293
+ filePath: join(testDir, "oldcodex.md"),
294
+ },
295
+ agents: [],
296
+ globalDir: null,
297
+ userDir: testDir,
298
+ projectDir: null,
299
+ editError: null,
300
+ editDirty: false,
301
+ };
302
+
303
+ saveAgent(state as any, () => {});
304
+
305
+ // The old file was deleted during the save (rename unlinkSync succeeded).
306
+ // newPath should NOT be deleted in the catch block — it is the only
307
+ // surviving copy of the agent.
308
+ const newPath = join(testDir, "codex.md");
309
+ expect(existsSync(newPath)).toBe(true);
310
+ const newContent = readFileSync(newPath, "utf-8");
311
+ expect(newContent).toContain("model: openai/gpt-4o");
312
+
313
+ // editError should contain the discovery failure message.
314
+ expect(state.editError).toContain("discovery failed");
315
+ });
316
+
317
+ it("rolls back both .md and JSON when re-discovery fails", () => {
318
+ // Allow writeLocalModel to succeed so execution proceeds past the JSON
319
+ // write to discoverAgentsAll, which will throw.
320
+ vi.mocked(writeLocalModel).mockImplementationOnce(() => {});
321
+ vi.mocked(discoverAgentsAll).mockImplementationOnce(() => {
322
+ throw new Error("discovery failed");
323
+ });
324
+
325
+ // Pre-create the .md file with original content (including old model).
326
+ const originalMd = [
327
+ "---",
328
+ "name: codex",
329
+ "description: original description",
330
+ "model: old-model",
331
+ "---",
332
+ "",
333
+ "original prompt",
334
+ "",
335
+ ].join("\n");
336
+ writeFileSync(join(testDir, "codex.md"), originalMd, "utf-8");
337
+
338
+ // Pre-create agents.local.json with the old model override.
339
+ writeFileSync(
340
+ join(testDir, "agents.local.json"),
341
+ JSON.stringify({ codex: { model: "old-model" } }),
342
+ "utf-8",
343
+ );
344
+
345
+ const state = {
346
+ editAgent: {
347
+ name: "codex",
348
+ description: "new description",
349
+ systemPrompt: "new prompt",
350
+ source: "user",
351
+ filePath: join(testDir, "codex.md"),
352
+ model: "new-model",
353
+ },
354
+ editOriginal: {
355
+ name: "codex",
356
+ description: "original description",
357
+ systemPrompt: "original prompt",
358
+ source: "user",
359
+ filePath: join(testDir, "codex.md"),
360
+ },
361
+ agents: [],
362
+ globalDir: null,
363
+ userDir: testDir,
364
+ projectDir: null,
365
+ editError: null,
366
+ editDirty: false,
367
+ };
368
+
369
+ saveAgent(state as any, () => {});
370
+
371
+ // .md file should be restored to original content (model: old-model).
372
+ const mdContent = readFileSync(join(testDir, "codex.md"), "utf-8");
373
+ expect(mdContent).toContain("model: old-model");
374
+ expect(mdContent).not.toContain("new description");
375
+
376
+ // agents.local.json should be restored to old-model via setLocalConfig.
377
+ const jsonContent = JSON.parse(
378
+ readFileSync(join(testDir, "agents.local.json"), "utf-8"),
379
+ );
380
+ expect(jsonContent.codex).toEqual({ model: "old-model" });
381
+
382
+ // The live edit object should still have the new model (retained for retry).
383
+ expect(state.editAgent!.model).toBe("new-model");
384
+
385
+ // editError should be set.
386
+ expect(state.editError).toBeTruthy();
387
+ });
388
+ });
389
+
390
+ describe("saveAgent double-delete prevention on retry", () => {
391
+ beforeEach(() => {
392
+ mkdirSync(testDir, { recursive: true });
393
+ });
394
+
395
+ afterEach(() => {
396
+ rmSync(testDir, { recursive: true, force: true });
397
+ });
398
+
399
+ it("does not delete newPath when old file already absent on retry", () => {
400
+ vi.mocked(writeLocalModel).mockImplementationOnce(() => {});
401
+ vi.mocked(writeLocalModel).mockImplementationOnce(() => {});
402
+ vi.mocked(discoverAgentsAll).mockImplementationOnce(() => {
403
+ throw new Error("discovery failed");
404
+ });
405
+ vi.mocked(discoverAgentsAll).mockImplementationOnce(() => {
406
+ throw new Error("discovery failed");
407
+ });
408
+ writeFileSync(join(testDir, "oldcodex.md"), "original rename content", "utf-8");
409
+ const state = {
410
+ editAgent: {
411
+ name: "codex",
412
+ description: "new description",
413
+ systemPrompt: "new prompt",
414
+ source: "user",
415
+ filePath: join(testDir, "oldcodex.md"),
416
+ model: "openai/gpt-4o",
417
+ },
418
+ editOriginal: {
419
+ name: "oldcodex",
420
+ description: "original description",
421
+ systemPrompt: "original prompt",
422
+ source: "user",
423
+ filePath: join(testDir, "oldcodex.md"),
424
+ },
425
+ agents: [],
426
+ globalDir: null,
427
+ userDir: testDir,
428
+ projectDir: null,
429
+ editError: null,
430
+ editDirty: false,
431
+ };
432
+ saveAgent(state as any, () => {});
433
+ expect(existsSync(join(testDir, "oldcodex.md"))).toBe(false);
434
+ const newPath = join(testDir, "codex.md");
435
+ expect(existsSync(newPath)).toBe(true);
436
+ saveAgent(state as any, () => {});
437
+ expect(existsSync(newPath)).toBe(true);
438
+ expect(state.editError).toContain("discovery failed");
439
+ });
440
+ });
441
+
442
+ describe("saveAgent cleanup of model-less new files", () => {
443
+ beforeEach(() => {
444
+ mkdirSync(testDir, { recursive: true });
445
+ });
446
+
447
+ afterEach(() => {
448
+ rmSync(testDir, { recursive: true, force: true });
449
+ });
450
+
451
+ it("deletes new file on failure when no model", () => {
452
+ vi.mocked(discoverAgentsAll).mockImplementationOnce(() => {
453
+ throw new Error("discovery failed");
454
+ });
455
+ const state = {
456
+ editAgent: {
457
+ name: "newagent",
458
+ description: "test agent",
459
+ systemPrompt: "hello world",
460
+ source: "user",
461
+ filePath: undefined,
462
+ },
463
+ editOriginal: {
464
+ name: "newagent",
465
+ description: "",
466
+ systemPrompt: "",
467
+ source: "user",
468
+ filePath: undefined,
469
+ },
470
+ agents: [],
471
+ globalDir: null,
472
+ userDir: testDir,
473
+ projectDir: null,
474
+ editError: null,
475
+ editDirty: false,
476
+ };
477
+ saveAgent(state as any, () => {});
478
+ const newPath = join(testDir, "newagent.md");
479
+ expect(existsSync(newPath)).toBe(false);
480
+ expect(state.editError).toBeTruthy();
481
+ });
482
+ });
483
+
package/src/types.ts CHANGED
@@ -7,6 +7,12 @@ export interface SubagentUsage {
7
7
  turns: number;
8
8
  }
9
9
 
10
+ export interface SubagentToolCall {
11
+ name: string;
12
+ argsPreview: string;
13
+ error: boolean;
14
+ }
15
+
10
16
  export interface SubagentProgress {
11
17
  agent: string;
12
18
  status: "running" | "completed" | "failed";
@@ -27,8 +33,8 @@ export interface SubagentProgress {
27
33
  output: string | undefined;
28
34
  /** Last N lines of assistant text for live display */
29
35
  recentOutput: string[] | undefined;
30
- /** History of tool calls: "toolName: args_preview" */
31
- toolCalls: string[] | undefined;
36
+ /** History of tool calls with status tracking */
37
+ toolCalls: SubagentToolCall[] | undefined;
32
38
  }
33
39
 
34
40
  export interface SubagentResult {
@@ -70,7 +76,7 @@ export interface StreamState {
70
76
  model: string | undefined;
71
77
  accumulatedOutput: string[];
72
78
  recentOutput: string[];
73
- toolCalls: string[];
79
+ toolCalls: SubagentToolCall[];
74
80
  finalOutput: string | undefined;
75
81
  }
76
82