@pi-archimedes/subagent 1.8.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }