@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 +2 -2
- package/src/agent-manager.ts +115 -4
- package/src/agents.test.ts +109 -0
- package/src/agents.ts +42 -1
- package/src/index.ts +69 -9
- package/src/local-config.test.ts +111 -0
- package/src/local-config.ts +74 -0
- package/src/model-validation.test.ts +82 -0
- package/src/model-validation.ts +93 -0
- package/src/save-agent.test.ts +483 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/subagent",
|
|
3
|
-
"version": "1.8.
|
|
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.
|
|
14
|
+
"@pi-archimedes/core": "1.8.3"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"@earendil-works/pi-ai": ">=0.1.0",
|
package/src/agent-manager.ts
CHANGED
|
@@ -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
|
-
//
|
|
1249
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
|