@pi-archimedes/subagent 1.8.1 → 1.8.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "1.8.1"
14
+ "@pi-archimedes/core": "1.8.3"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -14,6 +14,13 @@ import {
14
14
  import type { AgentConfig } from "./agents.js";
15
15
  import { discoverAgentsAll } from "./agents.js";
16
16
  import { serializeAgent, validateAgentName } from "./frontmatter-io.js";
17
+ import {
18
+ writeLocalModel,
19
+ deleteLocalModel,
20
+ readLocalConfig,
21
+ setLocalConfig,
22
+ type LocalConfig,
23
+ } from "./local-config.js";
17
24
 
18
25
  // ── Screen constants ────────────────────────────────────────────────────────
19
26
 
@@ -1204,7 +1211,7 @@ function setFieldValue(agent: AgentConfig, field: EditField, value: string): voi
1204
1211
 
1205
1212
  // ── Save logic ──────────────────────────────────────────────────────────────
1206
1213
 
1207
- function saveAgent(state: ManagerState, requestRender: () => void): void {
1214
+ export function saveAgent(state: ManagerState, requestRender: () => void): void {
1208
1215
  if (!state.editAgent) return;
1209
1216
 
1210
1217
  const agent = state.editAgent;
@@ -1241,18 +1248,77 @@ function saveAgent(state: ManagerState, requestRender: () => void): void {
1241
1248
  const newName = agent.name.endsWith(".md") ? agent.name : `${agent.name}.md`;
1242
1249
  const newPath = path.join(dir, newName);
1243
1250
 
1251
+ // Capture model before entering the try block so it is available in the
1252
+ // catch block for .md rollback if a later step (JSON write, etc.) fails.
1253
+ const model = agent.model;
1254
+
1255
+ // Track whether the .md write succeeded so the catch block knows whether
1256
+ // to restore or clean up the on-disk file.
1257
+ const isRename = oldPath && oldPath !== newPath;
1258
+ let originalContent: string | undefined;
1259
+ if (!isRename && fs.existsSync(newPath)) {
1260
+ // Read existing .md content so we can restore it verbatim if a later
1261
+ // step (JSON write, re-discovery, etc.) fails.
1262
+ originalContent = fs.readFileSync(newPath, "utf-8");
1263
+ }
1264
+ let mdWritten = false;
1265
+ // Track whether the old .md file was already removed during a rename so
1266
+ // the catch block knows whether newPath is the sole surviving copy.
1267
+ let oldPathDeleted = false;
1268
+ // Track whether the JSON config write succeeded so the catch block can
1269
+ // roll it back if a later step (re-discovery, etc.) fails.
1270
+ let jsonWritten = false;
1271
+ // Snapshot of the JSON config captured before the write so the catch
1272
+ // block can restore it. Declared here (not inside try) so it is
1273
+ // accessible in the catch block.
1274
+ let jsonConfigBefore: LocalConfig = {};
1275
+
1244
1276
  try {
1245
1277
  // Ensure directory exists
1246
1278
  fs.mkdirSync(dir, { recursive: true });
1247
1279
 
1248
- // Serialize and write
1249
- const content = serializeAgent(agent);
1280
+ // Build a shallow copy without the model for .md serialization so the
1281
+ // live edit object is NOT mutated during serialization. If the .md
1282
+ // write fails below, the live object stays intact for a retry.
1283
+ const mdAgent = { ...agent };
1284
+ delete mdAgent.model;
1285
+
1286
+ // Serialize and write the .md file FIRST. If this fails, no JSON state
1287
+ // is persisted and the live edit object is untouched.
1288
+ const content = serializeAgent(mdAgent);
1250
1289
  fs.writeFileSync(newPath, content, "utf-8");
1290
+ mdWritten = true;
1291
+
1292
+ // Only after the .md write succeeds, perform JSON store mutations.
1293
+ // Capture a backup of the current JSON config so we can roll it back
1294
+ // if a later step (re-discovery, etc.) fails after this write succeeds.
1295
+ jsonConfigBefore = readLocalConfig();
1296
+ // Write/remove the NEW name entry first, then clean up the OLD name.
1297
+ if (model !== undefined) {
1298
+ writeLocalModel(agent.name, model);
1299
+ } else {
1300
+ deleteLocalModel(agent.name);
1301
+ }
1302
+ jsonWritten = true;
1303
+
1304
+ // Handle rename: delete old JSON entry keyed by original name (after
1305
+ // the new entry is safely written). Wrapped in try-catch so a failure
1306
+ // here does not leave the .md written but the live object un-stripped.
1307
+ const originalName = state.editOriginal?.name;
1308
+ if (originalName && originalName !== agent.name) {
1309
+ try {
1310
+ deleteLocalModel(originalName);
1311
+ } catch {
1312
+ // Best-effort: stale entry is harmless and will be cleaned up on
1313
+ // a subsequent save/rename
1314
+ }
1315
+ }
1251
1316
 
1252
- // Handle rename if name changed
1317
+ // Handle rename: delete old .md file if name changed
1253
1318
  if (oldPath && oldPath !== newPath) {
1254
1319
  try {
1255
1320
  fs.unlinkSync(oldPath);
1321
+ oldPathDeleted = true;
1256
1322
  } catch {
1257
1323
  // Old file may not exist (e.g., new agent)
1258
1324
  }
@@ -1281,7 +1347,52 @@ function saveAgent(state: ManagerState, requestRender: () => void): void {
1281
1347
  state.editDirty = false;
1282
1348
  state.editError = null;
1283
1349
  requestRender();
1350
+
1351
+ // Only strip model from the live edit object AFTER all operations
1352
+ // (including re-discovery) have succeeded. This ensures that if any
1353
+ // step fails, the catch block can restore the model to .md and the
1354
+ // live object retains it for a safe retry.
1355
+ delete agent.model;
1284
1356
  } catch (err) {
1357
+ // Restore prior .md state if the write succeeded but a later step
1358
+ // (JSON write, re-discovery, etc.) failed:
1359
+ // - rename (old file not yet unlinked): check whether the old file
1360
+ // still exists. If so, delete newPath so only the original remains.
1361
+ // If the old file is gone (deleted externally or by a prior attempt),
1362
+ // newPath may be the sole copy — keep it with a model fallback, or
1363
+ // delete it when there is no model to fall back on.
1364
+ // - existing file: write the original content back verbatim.
1365
+ // - new file with a model: keep a frontmatter fallback so the model
1366
+ // override survives for the next retry.
1367
+ // If the old .md was already unlinked during rename (oldPathDeleted),
1368
+ // newPath is the sole surviving copy — leave it in place.
1369
+ // If the .md write itself failed (mdWritten is false) there is nothing
1370
+ // to restore on disk.
1371
+ if (mdWritten) {
1372
+ if (isRename && !oldPathDeleted) {
1373
+ if (oldPath && fs.existsSync(oldPath)) {
1374
+ // Old file still exists — safe to delete newPath and restore prior state
1375
+ try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1376
+ } else if (model !== undefined) {
1377
+ // Old file is gone — keep newPath with model as fallback
1378
+ try { fs.writeFileSync(newPath, serializeAgent({ ...agent, model }), "utf-8"); } catch { /* best-effort */ }
1379
+ } else {
1380
+ // Old file is gone and no model — delete newPath (no prior state to restore)
1381
+ try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1382
+ }
1383
+ } else if (originalContent !== undefined) {
1384
+ try { fs.writeFileSync(newPath, originalContent, "utf-8"); } catch { /* best-effort */ }
1385
+ } else if (model !== undefined) {
1386
+ try { fs.writeFileSync(newPath, serializeAgent({ ...agent, model }), "utf-8"); } catch { /* best-effort */ }
1387
+ } else {
1388
+ // New file without model — delete it (no prior state to restore)
1389
+ try { fs.unlinkSync(newPath); } catch { /* best-effort */ }
1390
+ }
1391
+ }
1392
+ // Roll back JSON if it was written but a later step failed
1393
+ if (jsonWritten) {
1394
+ try { setLocalConfig(jsonConfigBefore); } catch { /* best-effort */ }
1395
+ }
1285
1396
  state.editError = err instanceof Error ? err.message : "Failed to save agent";
1286
1397
  requestRender();
1287
1398
  }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import type { AgentConfig } from "./agents.js";
6
+ import { applyLocalOverrides, formatAgentList } from "./agents.js";
7
+
8
+ // Redirect getAgentDir() to a temp directory via PI_CODING_AGENT_DIR.
9
+ // This must happen before any function calls so readLocalConfig()
10
+ // resolves agents.local.json to our sandbox directory.
11
+ const testDir = join(tmpdir(), "pi-test-local-config-agents");
12
+ process.env.PI_CODING_AGENT_DIR = testDir;
13
+
14
+ function makeAgent(
15
+ name: string,
16
+ model?: string,
17
+ ): AgentConfig {
18
+ return {
19
+ name,
20
+ description: `Agent ${name}`,
21
+ systemPrompt: "hello",
22
+ source: "global" as const,
23
+ filePath: join(testDir, `${name}.md`),
24
+ ...(model !== undefined ? { model } : {}),
25
+ };
26
+ }
27
+
28
+ describe("applyLocalOverrides", () => {
29
+ beforeEach(() => {
30
+ mkdirSync(testDir, { recursive: true });
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(testDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("sets model from JSON override", () => {
38
+ const path = join(testDir, "agents.local.json");
39
+ writeFileSync(path, JSON.stringify({ codex: { model: "o1" } }), "utf-8");
40
+
41
+ const agent = makeAgent("codex");
42
+ applyLocalOverrides([agent]);
43
+ expect(agent.model).toBe("o1");
44
+ });
45
+
46
+ it("leaves model unchanged when no JSON entry exists", () => {
47
+ const agent = makeAgent("codex", "M1");
48
+ applyLocalOverrides([agent]);
49
+ expect(agent.model).toBe("M1");
50
+ });
51
+
52
+ it("leaves model unchanged when JSON entry has no model field", () => {
53
+ const path = join(testDir, "agents.local.json");
54
+ writeFileSync(path, JSON.stringify({ codex: {} }), "utf-8");
55
+
56
+ const agent = makeAgent("codex", "M1");
57
+ applyLocalOverrides([agent]);
58
+ expect(agent.model).toBe("M1");
59
+ });
60
+
61
+ it("handles empty agent list", () => {
62
+ expect(() => applyLocalOverrides([])).not.toThrow();
63
+ });
64
+
65
+ it("works with corrupt JSON file (agent model stays unchanged)", () => {
66
+ const path = join(testDir, "agents.local.json");
67
+ writeFileSync(path, "{ broken json", "utf-8");
68
+
69
+ const agent = makeAgent("codex", "M1");
70
+ applyLocalOverrides([agent]);
71
+ expect(agent.model).toBe("M1");
72
+ });
73
+ });
74
+
75
+ function mkAgent(overrides: Partial<AgentConfig> & Pick<AgentConfig, "name">): AgentConfig {
76
+ return {
77
+ name: overrides.name,
78
+ description: overrides.description ?? "desc",
79
+ systemPrompt: overrides.systemPrompt ?? "prompt",
80
+ source: overrides.source ?? "user",
81
+ filePath: overrides.filePath ?? "/x.md",
82
+ ...(overrides.model !== undefined ? { model: overrides.model } : {}),
83
+ ...(overrides.tools !== undefined ? { tools: overrides.tools } : {}),
84
+ };
85
+ }
86
+
87
+ describe("formatAgentList", () => {
88
+ it("reports no agents when empty", () => {
89
+ expect(formatAgentList([])).toContain("No agents configured");
90
+ });
91
+
92
+ it("formats a single agent (singular)", () => {
93
+ const out = formatAgentList([mkAgent({ name: "general" })]);
94
+ expect(out).toContain("1 agent:");
95
+ expect(out).toContain("• general [user] — desc");
96
+ });
97
+
98
+ it("formats multiple agents (plural)", () => {
99
+ const out = formatAgentList([mkAgent({ name: "general" }), mkAgent({ name: "explore" })]);
100
+ expect(out).toContain("2 agents:");
101
+ });
102
+
103
+ it("includes model and tools overrides only when set", () => {
104
+ const withExtras = formatAgentList([mkAgent({ name: "reviewer", model: "anthropic/claude-sonnet-4-5", tools: ["read", "bash"] })]);
105
+ expect(withExtras).toContain("(model: anthropic/claude-sonnet-4-5, 2 tools)");
106
+ const withoutExtras = formatAgentList([mkAgent({ name: "general" })]);
107
+ expect(withoutExtras).not.toMatch(/\(model:|tools\)/);
108
+ });
109
+ });
package/src/agents.ts CHANGED
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
+ import { readLocalConfig } from "./local-config.js";
10
11
 
11
12
  export interface AgentConfig {
12
13
  name: string;
@@ -136,6 +137,20 @@ export interface AgentsDiscoveryResult {
136
137
  projectDir: string | null; // e.g., .pi/agents or null if not found
137
138
  }
138
139
 
140
+ /**
141
+ * Apply local model overrides from agents.local.json to a list of agents.
142
+ * Reads the config once and mutates matching agents in place.
143
+ */
144
+ export function applyLocalOverrides(agents: AgentConfig[]): void {
145
+ const config = readLocalConfig();
146
+ for (const agent of agents) {
147
+ const local = config[agent.name];
148
+ if (local?.model !== undefined) {
149
+ agent.model = local.model;
150
+ }
151
+ }
152
+ }
153
+
139
154
  /**
140
155
  * Discover all available agents from global, user and/or project directories.
141
156
  * Precedence (highest last): global < user < project
@@ -155,7 +170,9 @@ export function discoverAgents(cwd: string): AgentConfig[] {
155
170
  for (const agent of userAgents) agentMap.set(agent.name, agent);
156
171
  for (const agent of projectAgents) agentMap.set(agent.name, agent);
157
172
 
158
- return Array.from(agentMap.values());
173
+ const all = Array.from(agentMap.values());
174
+ applyLocalOverrides(all);
175
+ return all;
159
176
  }
160
177
 
161
178
  /**
@@ -170,6 +187,10 @@ export function discoverAgentsAll(cwd: string): AgentsDiscoveryResult {
170
187
  const userAgents = loadAgentsFromDir(userDir, "user");
171
188
  const projectAgents = projectDir ? loadAgentsFromDir(projectDir, "project") : [];
172
189
 
190
+ applyLocalOverrides(globalAgents);
191
+ applyLocalOverrides(userAgents);
192
+ applyLocalOverrides(projectAgents);
193
+
173
194
  return {
174
195
  global: globalAgents,
175
196
  user: userAgents,
@@ -186,3 +207,23 @@ export function discoverAgentsAll(cwd: string): AgentsDiscoveryResult {
186
207
  export function findAgent(agents: AgentConfig[], name: string): AgentConfig | undefined {
187
208
  return agents.find((a) => a.name === name);
188
209
  }
210
+
211
+ /**
212
+ * Format discovered agents as a compact, readable listing for the `list_agents`
213
+ * tool. Standard detail: name, source, description, and model/tools overrides
214
+ * only when set.
215
+ */
216
+ export function formatAgentList(agents: AgentConfig[]): string {
217
+ if (agents.length === 0) {
218
+ return "No agents configured. Create one in ~/.pi/agent/agents/ or .agents/agents/.";
219
+ }
220
+ const lines = agents.map((a) => {
221
+ let line = `• ${a.name} [${a.source}] — ${a.description}`;
222
+ const extras: string[] = [];
223
+ if (a.model) extras.push(`model: ${a.model}`);
224
+ if (a.tools && a.tools.length > 0) extras.push(`${a.tools.length} tools`);
225
+ if (extras.length > 0) line += ` (${extras.join(", ")})`;
226
+ return line;
227
+ });
228
+ return `${agents.length} agent${agents.length === 1 ? "" : "s"}:\n${lines.join("\n")}`;
229
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,8 @@ import { Text, TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  // execute.js + agent-manager.js lazy-loaded below to keep subagent tool registration fast
5
5
  import { renderSubagentResult } from "./render.js";
6
- import { discoverAgents, discoverAgentsAll, findAgent } from "./agents.js";
6
+ import { discoverAgents, discoverAgentsAll, findAgent, formatAgentList } from "./agents.js";
7
+ import { validateModel, firstError } from "./model-validation.js";
7
8
  import type {
8
9
  SubagentDetails,
9
10
  SubagentProgress,
@@ -14,7 +15,9 @@ import type {
14
15
  // ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
15
16
 
16
17
  const TaskItem = Type.Object({
17
- agent: Type.Optional(Type.String()),
18
+ agent: Type.Optional(Type.String({
19
+ description: "Agent name for this task (optional). If omitted, runs config-less.",
20
+ })),
18
21
  task: Type.String(),
19
22
  model: Type.Optional(Type.String()),
20
23
  cwd: Type.Optional(Type.String()),
@@ -22,7 +25,7 @@ const TaskItem = Type.Object({
22
25
 
23
26
  const SUBAGENT_PARAMS_SCHEMA = Type.Object({
24
27
  agent: Type.Optional(Type.String({
25
- description: "Agent name/identifier (optional, defaults to 'general')",
28
+ description: "Agent name (optional). If omitted, the subagent runs config-less — parent's current model, all tools, no system-prompt override. Call list_agents to see available agents.",
26
29
  })),
27
30
  task: Type.Optional(Type.String({
28
31
  description: "Task description for the subagent. Required when not using 'tasks' array.",
@@ -55,7 +58,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
55
58
  name: "subagent",
56
59
  label: "Subagent",
57
60
  description:
58
- "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Never omit both. Options: agent, model, cwd.",
61
+ "Delegate tasks to subagents. Provide either 'task' (single) or 'tasks' (parallel). Agent is optional — omit for a config-less run with the parent's model and all tools. Model override is rarely needed; the agent config or parent model is used by default.",
59
62
  parameters: SUBAGENT_PARAMS_SCHEMA,
60
63
 
61
64
  async execute(
@@ -78,12 +81,34 @@ export function registerSubagent(pi: ExtensionAPI): void {
78
81
 
79
82
  // Parallel mode
80
83
  if (params.tasks && params.tasks.length > 0) {
81
- const missingAgents = params.tasks.filter((t) => t.agent && !findAgent(agents, t.agent));
82
- if (missingAgents.length > 0) {
84
+ // Combined pre-spawn checks for parallel mode: unknown agents + invalid
85
+ // models. If ANY task is invalid, abort the whole batch with a single
86
+ // tool result listing all errors (no tasks spawn).
87
+ const errors: string[] = [];
88
+ const unknownAgents = params.tasks
89
+ .filter((t) => t.agent && !findAgent(agents, t.agent!))
90
+ .map((t) => `"${t.agent}"`);
91
+ if (unknownAgents.length > 0) {
83
92
  const available = agents.map((a) => a.name).join(", ") || "none";
84
- const unknown = missingAgents.map((t) => `"${t.agent}"`).join(", ");
93
+ errors.push(`Unknown agent(s): ${unknownAgents.join(", ")}. Available: ${available}. Call list_agents for details.`);
94
+ }
95
+ const unknownAgentSet = new Set(unknownAgents.map((n) => n.replace(/"/g, '')));
96
+ for (const t of params.tasks) {
97
+ // Skip model validation for tasks already caught by unknown-agent check
98
+ if (t.agent && unknownAgentSet.has(t.agent)) continue;
99
+ const taskAgentConfig = t.agent ? findAgent(agents, t.agent) : undefined;
100
+ const me = firstError(
101
+ validateModel(t.model, ctx.modelRegistry, { agentName: t.agent }),
102
+ validateModel(taskAgentConfig?.model, ctx.modelRegistry, {
103
+ agentName: t.agent,
104
+ agentFilePath: taskAgentConfig?.filePath,
105
+ }),
106
+ );
107
+ if (me) errors.push(me);
108
+ }
109
+ if (errors.length > 0) {
85
110
  return {
86
- content: [{ type: "text", text: `Unknown agent(s): ${unknown}. Available: ${available}` }],
111
+ content: [{ type: "text", text: errors.join("\n") }],
87
112
  details: {
88
113
  mode: "parallel",
89
114
  results: [],
@@ -134,7 +159,7 @@ export function registerSubagent(pi: ExtensionAPI): void {
134
159
  if (params.agent && !agentConfig) {
135
160
  const available = agents.map((a) => a.name).join(", ") || "none";
136
161
  return {
137
- content: [{ type: "text", text: `Unknown agent: "${params.agent}". Available: ${available}` }],
162
+ content: [{ type: "text", text: `Unknown agent: "${params.agent}". Available: ${available}. Call list_agents for details.` }],
138
163
  details: {
139
164
  mode: "single",
140
165
  results: [],
@@ -143,6 +168,22 @@ export function registerSubagent(pi: ExtensionAPI): void {
143
168
  isError: true,
144
169
  };
145
170
  }
171
+ // Pre-spawn model validation (P2): fail fast with a friendly error
172
+ // instead of spawning a child that will crash on a bogus --model.
173
+ const modelError = firstError(
174
+ validateModel(params.model, ctx.modelRegistry, { agentName: params.agent }),
175
+ validateModel(agentConfig?.model, ctx.modelRegistry, {
176
+ agentName: params.agent,
177
+ agentFilePath: agentConfig?.filePath,
178
+ }),
179
+ );
180
+ if (modelError) {
181
+ return {
182
+ content: [{ type: "text", text: modelError }],
183
+ details: { mode: "single", results: [], progress: undefined },
184
+ isError: true,
185
+ };
186
+ }
146
187
  const result: SubagentResult = await executeSubagent({
147
188
  agent: params.agent ?? undefined,
148
189
  agentConfig,
@@ -234,10 +275,29 @@ export function registerSubagent(pi: ExtensionAPI): void {
234
275
  }
235
276
  },
236
277
  });
278
+
279
+ registerListAgentsTool(pi);
237
280
  }
238
281
 
239
282
  // ── Helpers ─────────────────────────────────────────────────────────────────
240
283
 
284
+ export function registerListAgentsTool(pi: ExtensionAPI): void {
285
+ pi.registerTool({
286
+ name: "list_agents",
287
+ label: "Agents",
288
+ description:
289
+ "List available subagent configurations (name, description, source, model/tools overrides). Call before dispatching if unsure which agents exist or which fits the task.",
290
+ parameters: Type.Object({}),
291
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
292
+ const agents = discoverAgents(ctx.cwd);
293
+ return {
294
+ content: [{ type: "text" as const, text: formatAgentList(agents) }],
295
+ details: { count: agents.length },
296
+ };
297
+ },
298
+ });
299
+ }
300
+
241
301
  function formatProgressSummary(progress: SubagentProgress[]): string {
242
302
  if (progress.length === 0) return "";
243
303
  const lines = progress.map((p) => {
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ mkdirSync,
4
+ rmSync,
5
+ existsSync,
6
+ readdirSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import {
12
+ readLocalConfig,
13
+ writeLocalModel,
14
+ deleteLocalModel,
15
+ } from "./local-config.js";
16
+
17
+ // Redirect getAgentDir() to a temp directory via PI_CODING_AGENT_DIR.
18
+ // This must happen before any function calls so getLocalConfigPath()
19
+ // resolves to our sandbox directory.
20
+ const testDir = join(tmpdir(), "pi-test-local-config");
21
+ process.env.PI_CODING_AGENT_DIR = testDir;
22
+
23
+ describe("local-config", () => {
24
+ beforeEach(() => {
25
+ mkdirSync(testDir, { recursive: true });
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(testDir, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("readLocalConfig", () => {
33
+ it("returns {} when file does not exist", () => {
34
+ expect(readLocalConfig()).toEqual({});
35
+ });
36
+
37
+ it("returns {} when file is corrupt JSON", () => {
38
+ const path = join(testDir, "agents.local.json");
39
+ writeFileSync(path, "{ broken json", "utf-8");
40
+ expect(readLocalConfig()).toEqual({});
41
+ });
42
+
43
+ it("parses valid JSON correctly", () => {
44
+ const path = join(testDir, "agents.local.json");
45
+ writeFileSync(
46
+ path,
47
+ JSON.stringify({ codex: { model: "o1" } }),
48
+ "utf-8",
49
+ );
50
+ expect(readLocalConfig()).toEqual({ codex: { model: "o1" } });
51
+ });
52
+ });
53
+
54
+ describe("writeLocalModel", () => {
55
+ it("creates file and writes model entry", () => {
56
+ writeLocalModel("codex", "o1");
57
+ expect(readLocalConfig()).toEqual({ codex: { model: "o1" } });
58
+ });
59
+
60
+ it("preserves other agent entries when updating one", () => {
61
+ writeLocalModel("codex", "o1");
62
+ writeLocalModel("claude", "claude-3.7");
63
+ expect(readLocalConfig()).toEqual({
64
+ codex: { model: "o1" },
65
+ claude: { model: "claude-3.7" },
66
+ });
67
+ });
68
+
69
+ it("handles model values with special characters", () => {
70
+ writeLocalModel("openai", "openai/gpt-4.1");
71
+ expect(readLocalConfig()).toEqual({
72
+ openai: { model: "openai/gpt-4.1" },
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("deleteLocalModel", () => {
78
+ it("removes the specified agent entry", () => {
79
+ writeLocalModel("codex", "o1");
80
+ deleteLocalModel("codex");
81
+ expect(readLocalConfig()).toEqual({});
82
+ });
83
+
84
+ it("is a no-op when agent does not exist", () => {
85
+ writeLocalModel("codex", "o1");
86
+ deleteLocalModel("nonexistent");
87
+ expect(readLocalConfig()).toEqual({ codex: { model: "o1" } });
88
+ });
89
+
90
+ it("preserves other entries", () => {
91
+ writeLocalModel("codex", "o1");
92
+ writeLocalModel("claude", "claude-3.7");
93
+ deleteLocalModel("codex");
94
+ expect(readLocalConfig()).toEqual({ claude: { model: "claude-3.7" } });
95
+ });
96
+
97
+ it("does not create file when deleting absent agent on clean install", () => {
98
+ // No agents.local.json exists yet. Deleting an absent agent should
99
+ // be a true no-op — it must NOT materialise an empty file on disk.
100
+ deleteLocalModel("nonexistent");
101
+ expect(existsSync(join(testDir, "agents.local.json"))).toBe(false);
102
+ });
103
+ });
104
+
105
+ it("leaves no .tmp file behind after successful write", () => {
106
+ writeLocalModel("codex", "o1");
107
+ const files = readdirSync(testDir);
108
+ expect(files).not.toContain("agents.local.json.tmp");
109
+ expect(existsSync(join(testDir, "agents.local.json"))).toBe(true);
110
+ });
111
+ });
@@ -0,0 +1,74 @@
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ existsSync,
5
+ renameSync,
6
+ unlinkSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+
11
+ /** Per-agent local overrides stored in agents.local.json */
12
+ export type LocalConfig = Record<string, { model?: string }>;
13
+
14
+ /** Returns the path to agents.local.json inside the agent directory. */
15
+ export function getLocalConfigPath(): string {
16
+ return join(getAgentDir(), "agents.local.json");
17
+ }
18
+
19
+ /** Read the full agents.local.json, returning {} if missing or corrupt. */
20
+ function readLocalConfigRaw(): LocalConfig {
21
+ const path = getLocalConfigPath();
22
+ if (!existsSync(path)) return {};
23
+ try {
24
+ return JSON.parse(readFileSync(path, "utf-8"));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ /** Read agents.local.json, returning {} if missing or corrupt. */
31
+ export function readLocalConfig(): LocalConfig {
32
+ return readLocalConfigRaw();
33
+ }
34
+
35
+ /**
36
+ * Write the full config atomically: write to .tmp then rename.
37
+ * Falls back to a direct write if rename fails; cleans up .tmp on failure.
38
+ * Follows the pattern in packages/core/src/settings-io.ts.
39
+ */
40
+ function writeConfigAtomic(config: LocalConfig): void {
41
+ const path = getLocalConfigPath();
42
+ const tmpPath = path + ".tmp";
43
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
44
+ try {
45
+ renameSync(tmpPath, path);
46
+ } catch {
47
+ try {
48
+ unlinkSync(tmpPath);
49
+ } catch {
50
+ // ignore — tmp file may not exist
51
+ }
52
+ writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
53
+ }
54
+ }
55
+
56
+ /** Set the local model override for a given agent, preserving existing entries. */
57
+ export function writeLocalModel(agentName: string, model: string): void {
58
+ const config = readLocalConfig();
59
+ config[agentName] = { ...config[agentName], model };
60
+ writeConfigAtomic(config);
61
+ }
62
+
63
+ /** Delete the local model override for a given agent (no-op if absent). */
64
+ export function deleteLocalModel(agentName: string): void {
65
+ const config = readLocalConfig();
66
+ if (!(agentName in config)) return;
67
+ delete config[agentName];
68
+ writeConfigAtomic(config);
69
+ }
70
+
71
+ /** Write the full local config atomically (backup+restore safe). */
72
+ export function setLocalConfig(config: LocalConfig): void {
73
+ writeConfigAtomic(config);
74
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { validateModel, firstError } from "./model-validation.js";
4
+
5
+ function mockRegistry(models: Array<{ provider: string; id: string }>): ModelRegistry {
6
+ return { getAll: () => models } as unknown as ModelRegistry;
7
+ }
8
+
9
+ const REGISTRY = mockRegistry([
10
+ { provider: "anthropic", id: "claude-sonnet-4-5" },
11
+ { provider: "openai", id: "gpt-5" },
12
+ { provider: "openrouter", id: "claude-sonnet-4-5" }, // ambiguous bare id across providers
13
+ ]);
14
+
15
+ describe("validateModel", () => {
16
+ it("accepts a valid canonical provider/id", () => {
17
+ expect(validateModel("anthropic/claude-sonnet-4-5", REGISTRY)).toEqual({ ok: true });
18
+ });
19
+
20
+ it("accepts case-insensitive provider/id", () => {
21
+ expect(validateModel("Anthropic/Claude-Sonnet-4-5", REGISTRY)).toEqual({ ok: true });
22
+ });
23
+
24
+ it("accepts a valid unique bare id", () => {
25
+ expect(validateModel("gpt-5", REGISTRY)).toEqual({ ok: true });
26
+ });
27
+
28
+ it("rejects an ambiguous bare id (>=2 providers)", () => {
29
+ const r = validateModel("claude-sonnet-4-5", REGISTRY);
30
+ expect(r.ok).toBe(false);
31
+ });
32
+
33
+ it("rejects an unknown string", () => {
34
+ const r = validateModel("general", REGISTRY);
35
+ expect(r.ok).toBe(false);
36
+ expect((r as { error: string }).error).toContain("not found");
37
+ });
38
+
39
+ it("accepts a thinking suffix by matching the prefix", () => {
40
+ expect(validateModel("gpt-5:high", REGISTRY)).toEqual({ ok: true });
41
+ });
42
+
43
+ it("accepts provider/id with a thinking suffix", () => {
44
+ expect(validateModel("anthropic/claude-sonnet-4-5:high", REGISTRY)).toEqual({ ok: true });
45
+ });
46
+
47
+ it("returns ok for empty/undefined model", () => {
48
+ expect(validateModel(undefined, REGISTRY)).toEqual({ ok: true });
49
+ expect(validateModel("", REGISTRY)).toEqual({ ok: true });
50
+ expect(validateModel(" ", REGISTRY)).toEqual({ ok: true });
51
+ });
52
+
53
+ it("returns ok when the registry is empty (defer to child)", () => {
54
+ expect(validateModel("anything", mockRegistry([]))).toEqual({ ok: true });
55
+ });
56
+
57
+ it("emits the agent-name hint when model equals agentName", () => {
58
+ const r = validateModel("general", REGISTRY, { agentName: "general" });
59
+ expect(r.ok).toBe(false);
60
+ expect((r as { error: string }).error).toContain("looks like an agent name");
61
+ });
62
+
63
+ it("emits the config-pointing message when agentFilePath is set", () => {
64
+ const r = validateModel("bogus", REGISTRY, {
65
+ agentName: "reviewer",
66
+ agentFilePath: "/home/u/.agents/agents/reviewer.md",
67
+ });
68
+ expect(r.ok).toBe(false);
69
+ const err = (r as { error: string }).error;
70
+ expect(err).toContain("/home/u/.agents/agents/reviewer.md");
71
+ expect(err).toContain("Fix the model field");
72
+ });
73
+ });
74
+
75
+ describe("firstError", () => {
76
+ it("returns undefined when all ok", () => {
77
+ expect(firstError({ ok: true }, { ok: true })).toBeUndefined();
78
+ });
79
+ it("returns the first error string", () => {
80
+ expect(firstError({ ok: true }, { ok: false, error: "boom" }, { ok: false, error: "later" })).toBe("boom");
81
+ });
82
+ });
@@ -0,0 +1,93 @@
1
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface ValidateModelContext {
4
+ /** Agent name the caller passed (used to detect the agent→model mirror footgun). */
5
+ agentName?: string | undefined;
6
+ /** File path of the agent config whose `model:` field is being validated. */
7
+ agentFilePath?: string | undefined;
8
+ }
9
+
10
+ export type ValidateModelResult = { ok: true } | { ok: false; error: string };
11
+
12
+ /**
13
+ * Find a model by reference, mirroring pi core's `findExactModelReferenceMatch`
14
+ * rules (that function is not exported; rules read from pi core source):
15
+ * - canonical "provider/id" (case-insensitive), OR
16
+ * - bare "id" (case-insensitive); ambiguous (>=2 providers share the id) → no match.
17
+ */
18
+ function findMatch<T extends { provider: string; id: string }>(
19
+ ref: string,
20
+ models: readonly T[],
21
+ ): T | undefined {
22
+ const lower = ref.toLowerCase();
23
+ if (!lower) return undefined;
24
+ // canonical provider/id
25
+ const canonical = models.find((m) => `${m.provider}/${m.id}`.toLowerCase() === lower);
26
+ if (canonical) return canonical;
27
+ // bare id — must be unique across providers
28
+ const idMatches = models.filter((m) => m.id.toLowerCase() === lower);
29
+ return idMatches.length === 1 ? idMatches[0] : undefined;
30
+ }
31
+
32
+ /**
33
+ * Validate that a model reference string resolves to a known model.
34
+ *
35
+ * Pure gate: returns `{ ok: true }` when valid (no resolved model object —
36
+ * callers forward the ORIGINAL string to spawn.ts unchanged, preserving any
37
+ * `:high` thinking suffix for the child's own resolver).
38
+ *
39
+ * Matching is against `registry.getAll()` (all known models — a pure
40
+ * name-existence check; auth is deferred to the child, matching how the
41
+ * child's `resolveCliModel` resolves against all models). Exact-match only;
42
+ * fuzzy/alias patterns the child might accept are rejected here (acceptable —
43
+ * `model` override is discouraged, so legitimate fuzzy usage is ~0).
44
+ */
45
+ export function validateModel(
46
+ model: string | undefined,
47
+ registry: ModelRegistry,
48
+ context: ValidateModelContext = {},
49
+ ): ValidateModelResult {
50
+ if (!model || !model.trim()) return { ok: true };
51
+ const all = registry.getAll();
52
+ if (all.length === 0) return { ok: true }; // unconfigured registry — defer to child
53
+
54
+ const ref = model.trim();
55
+ let matched = findMatch(ref, all);
56
+ // Thinking-suffix tolerant: if the full string failed but it has a colon,
57
+ // retry with the prefix before the last colon (handles "claude-sonnet-4-5:high").
58
+ if (!matched && ref.includes(":")) {
59
+ const prefix = ref.slice(0, ref.lastIndexOf(":"));
60
+ if (prefix) matched = findMatch(prefix, all);
61
+ }
62
+ if (matched) return { ok: true };
63
+
64
+ const sample = all.slice(0, 5).map((m) => `${m.provider}/${m.id}`).join(", ");
65
+ const more = all.length > 5 ? `, … (${all.length} total)` : "";
66
+
67
+ // Prioritize file-path message when both agentName and agentFilePath are set
68
+ // (more actionable than the generic agent-name hint)
69
+ if (context.agentFilePath) {
70
+ return {
71
+ ok: false,
72
+ error: `Agent "${context.agentName ?? "unknown"}" is configured with an invalid model "${model}" (in ${context.agentFilePath}). Fix the model field or agents.local.json. Available: ${sample}${more}.`,
73
+ };
74
+ }
75
+ if (context.agentName && ref === context.agentName.trim()) {
76
+ return {
77
+ ok: false,
78
+ error: `Model "${model}" not found — it looks like an agent name, not a model. Omit the model parameter; the agent's configured model or the parent's current model will be used. Available models include: ${sample}${more}.`,
79
+ };
80
+ }
81
+ return {
82
+ ok: false,
83
+ error: `Model "${model}" not found. Available models include: ${sample}${more}.`,
84
+ };
85
+ }
86
+
87
+ /** Return the first error message from the given results, or undefined if all ok. */
88
+ export function firstError(...results: ValidateModelResult[]): string | undefined {
89
+ for (const r of results) {
90
+ if (!r.ok) return r.error;
91
+ }
92
+ return undefined;
93
+ }
@@ -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
+