@ilya-lesikov/pi-pi 0.5.0 → 0.6.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.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1177 -341
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +287 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2557 -347
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -0,0 +1,219 @@
1
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import { join } from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { addTaskDestination, flushLogs, getLogger, initSessionLogger, removeTaskDestination, resetLogger, setLogLevel } from "./log.js";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ function makeTempDir(): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "pi-pi-log-test-"));
11
+ tempDirs.push(dir);
12
+ return dir;
13
+ }
14
+
15
+ function delay(ms: number): Promise<void> {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ function readText(path: string): string {
20
+ return existsSync(path) ? readFileSync(path, "utf-8") : "";
21
+ }
22
+
23
+ async function waitForContent(path: string, substring: string, timeoutMs = 2000): Promise<string> {
24
+ const deadline = Date.now() + timeoutMs;
25
+ let text = readText(path);
26
+ while (!text.includes(substring) && Date.now() < deadline) {
27
+ await delay(10);
28
+ text = readText(path);
29
+ }
30
+ return text;
31
+ }
32
+
33
+ function sessionLogPath(ppDir: string): string {
34
+ const logsDir = join(ppDir, "logs");
35
+ const entries = existsSync(logsDir)
36
+ ? readdirSync(logsDir).filter((f) => f.startsWith("session-") && f.endsWith(".jsonl"))
37
+ : [];
38
+ if (entries.length === 0) return "";
39
+ return join(logsDir, entries.sort()[entries.length - 1]);
40
+ }
41
+
42
+ afterEach(async () => {
43
+ flushLogs();
44
+ resetLogger();
45
+ await delay(50);
46
+ for (const dir of tempDirs.splice(0)) {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ describe("log", () => {
52
+ it("getLogger returns silent logger before init", async () => {
53
+ await (async () => {
54
+ const module = await import("./log.js");
55
+ expect(module.getLogger().level).toBe("silent");
56
+ })();
57
+ });
58
+
59
+ it("initSessionLogger creates session log file in .pp/logs", async () => {
60
+ const ppDir = makeTempDir();
61
+ initSessionLogger(ppDir, "info");
62
+ getLogger().info({ s: "test" }, "create-session-log");
63
+ flushLogs();
64
+ await delay(30);
65
+
66
+ const logPath = sessionLogPath(ppDir);
67
+ expect(logPath).not.toBe("");
68
+ expect(existsSync(logPath)).toBe(true);
69
+ });
70
+
71
+ it("initSessionLogger writes JSONL to session file", async () => {
72
+ const ppDir = makeTempDir();
73
+ initSessionLogger(ppDir, "debug");
74
+
75
+ getLogger().info({ s: "test" }, "touch-session-log");
76
+ flushLogs();
77
+ await delay(60);
78
+
79
+ const logPath = sessionLogPath(ppDir);
80
+ expect(logPath).not.toBe("");
81
+ expect(existsSync(logPath)).toBe(true);
82
+
83
+ getLogger().info({ s: "test" }, "hello-session");
84
+ flushLogs();
85
+ await delay(60);
86
+
87
+ expect(readText(logPath)).toContain("hello-session");
88
+ });
89
+
90
+ it("addTaskDestination creates debug.jsonl under .pp/logs (re-rooted from .pp/state)", async () => {
91
+ const cwd = makeTempDir();
92
+ const ppDir = join(cwd, ".pp");
93
+ const stateTaskDir = join(ppDir, "state", "implement", "abc_task");
94
+ const logsTaskDir = join(ppDir, "logs", "implement", "abc_task");
95
+ initSessionLogger(ppDir, "info");
96
+
97
+ addTaskDestination(stateTaskDir);
98
+ getLogger().info({ s: "test" }, "touch-task-log");
99
+ flushLogs();
100
+ await delay(30);
101
+
102
+ expect(existsSync(join(logsTaskDir, "debug.jsonl"))).toBe(true);
103
+ expect(existsSync(join(stateTaskDir, "debug.jsonl"))).toBe(false);
104
+ });
105
+
106
+ it("addTaskDestination logs to both session and task files", async () => {
107
+ const cwd = makeTempDir();
108
+ const ppDir = join(cwd, ".pp");
109
+ const stateTaskDir = join(ppDir, "state", "implement", "abc_task");
110
+ const logsTaskDir = join(ppDir, "logs", "implement", "abc_task");
111
+ initSessionLogger(ppDir, "debug");
112
+ const sessionPath = sessionLogPath(ppDir);
113
+ addTaskDestination(stateTaskDir);
114
+
115
+ getLogger().info({ s: "test" }, "both-destinations");
116
+ flushLogs();
117
+
118
+ expect(await waitForContent(sessionPath, "both-destinations")).toContain("both-destinations");
119
+ expect(await waitForContent(join(logsTaskDir, "debug.jsonl"), "both-destinations")).toContain("both-destinations");
120
+ });
121
+
122
+ it("removeTaskDestination stops writing to task file", async () => {
123
+ const cwd = makeTempDir();
124
+ const ppDir = join(cwd, ".pp");
125
+ const stateTaskDir = join(ppDir, "state", "implement", "abc_task");
126
+ const logsTaskDir = join(ppDir, "logs", "implement", "abc_task");
127
+ initSessionLogger(ppDir, "debug");
128
+ addTaskDestination(stateTaskDir);
129
+
130
+ getLogger().info({ s: "test" }, "before-remove");
131
+ flushLogs();
132
+ const before = await waitForContent(join(logsTaskDir, "debug.jsonl"), "before-remove");
133
+
134
+ removeTaskDestination();
135
+ getLogger().info({ s: "test" }, "after-remove");
136
+ flushLogs();
137
+ await delay(30);
138
+ const after = readText(join(logsTaskDir, "debug.jsonl"));
139
+
140
+ expect(before).toContain("before-remove");
141
+ expect(after).not.toContain("after-remove");
142
+ });
143
+
144
+ it("setLogLevel changes effective log level", async () => {
145
+ const ppDir = makeTempDir();
146
+ initSessionLogger(ppDir, "info");
147
+ const path = sessionLogPath(ppDir);
148
+
149
+ getLogger().debug({ s: "test" }, "debug-before");
150
+ getLogger().info({ s: "test" }, "info-before");
151
+ flushLogs();
152
+ const before = await waitForContent(path, "info-before");
153
+
154
+ setLogLevel("debug");
155
+ getLogger().debug({ s: "test" }, "debug-after");
156
+ flushLogs();
157
+ const after = await waitForContent(path, "debug-after");
158
+
159
+ expect(before).toContain("info-before");
160
+ expect(before).not.toContain("debug-before");
161
+ expect(after).toContain("debug-after");
162
+ });
163
+
164
+ it("setLogLevel ignores invalid levels", () => {
165
+ const ppDir = makeTempDir();
166
+ initSessionLogger(ppDir, "info");
167
+
168
+ setLogLevel("invalid" as any);
169
+
170
+ expect(getLogger().level).toBe("info");
171
+ });
172
+
173
+ it("cleanOldSessionLogs deletes files older than max age", () => {
174
+ const ppDir = makeTempDir();
175
+ const logsDir = join(ppDir, "logs");
176
+ mkdirSync(logsDir, { recursive: true });
177
+ const oldFile = join(logsDir, "session-old.jsonl");
178
+ writeFileSync(oldFile, "{}\n", "utf-8");
179
+ const oldTime = new Date(Date.now() - 9 * 24 * 60 * 60 * 1000);
180
+ utimesSync(oldFile, oldTime, oldTime);
181
+
182
+ initSessionLogger(ppDir, "info");
183
+
184
+ expect(existsSync(oldFile)).toBe(false);
185
+ });
186
+
187
+ it("cleanOldSessionLogs keeps recent files", () => {
188
+ const ppDir = makeTempDir();
189
+ const logsDir = join(ppDir, "logs");
190
+ mkdirSync(logsDir, { recursive: true });
191
+ const recentFile = join(logsDir, "session-recent.jsonl");
192
+ writeFileSync(recentFile, "{}\n", "utf-8");
193
+ const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
194
+ utimesSync(recentFile, recentTime, recentTime);
195
+
196
+ initSessionLogger(ppDir, "info");
197
+
198
+ expect(existsSync(recentFile)).toBe(true);
199
+ });
200
+
201
+ it("initSessionLogger falls back to silent on unwritable path", () => {
202
+ const dir = makeTempDir();
203
+ const filePath = join(dir, "not-a-dir");
204
+ writeFileSync(filePath, "x", "utf-8");
205
+
206
+ initSessionLogger(filePath, "debug");
207
+
208
+ expect(getLogger().level).toBe("silent");
209
+ });
210
+
211
+ it("flushLogs does not crash when no streams", () => {
212
+ const dir = makeTempDir();
213
+ const filePath = join(dir, "not-a-dir");
214
+ writeFileSync(filePath, "x", "utf-8");
215
+ initSessionLogger(filePath, "info");
216
+
217
+ expect(() => flushLogs()).not.toThrow();
218
+ });
219
+ });
@@ -0,0 +1,153 @@
1
+ import pino from "pino";
2
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
3
+ import { join, sep } from "path";
4
+
5
+ export type LogLevel = "debug" | "info" | "warn" | "error";
6
+
7
+ const VALID_LOG_LEVELS: readonly string[] = ["debug", "info", "warn", "error"];
8
+
9
+ export function isValidLogLevel(value: unknown): value is LogLevel {
10
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value);
11
+ }
12
+
13
+ interface MsStream {
14
+ id: number;
15
+ stream: pino.DestinationStream;
16
+ level: number;
17
+ }
18
+
19
+ interface Multistream extends pino.DestinationStream {
20
+ add(dest: { stream: pino.DestinationStream; level: string }): void;
21
+ remove(id: number): void;
22
+ flushSync(): void;
23
+ lastId: number;
24
+ streams: MsStream[];
25
+ }
26
+
27
+ let sessionStreamId = -1;
28
+ let taskStreamId = -1;
29
+ let taskStreamRef: pino.DestinationStream | null = null;
30
+ let sessionStreamRef: pino.DestinationStream | null = null;
31
+ let ms: Multistream | null = null;
32
+ let logger: pino.Logger = pino({ level: "silent" });
33
+
34
+ function safeEndStream(stream: pino.DestinationStream | null): void {
35
+ if (!stream) return;
36
+ try { (stream as any).flushSync?.(); } catch {}
37
+ try { (stream as any).end?.(); } catch {}
38
+ }
39
+
40
+ export function initSessionLogger(ppDir: string, level: LogLevel = "info"): void {
41
+ try {
42
+ if (ms && sessionStreamId >= 0) {
43
+ ms.remove(sessionStreamId);
44
+ safeEndStream(sessionStreamRef);
45
+ }
46
+ if (ms && taskStreamId >= 0) {
47
+ ms.remove(taskStreamId);
48
+ safeEndStream(taskStreamRef);
49
+ taskStreamId = -1;
50
+ taskStreamRef = null;
51
+ }
52
+
53
+ const logsDir = join(ppDir, "logs");
54
+ mkdirSync(logsDir, { recursive: true });
55
+ cleanOldSessionLogs(logsDir, 7);
56
+
57
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
58
+ const logFile = join(logsDir, `session-${ts}.jsonl`);
59
+ sessionStreamRef = pino.destination({ dest: logFile, sync: false });
60
+ ms = pino.multistream([{ stream: sessionStreamRef, level }]) as unknown as Multistream;
61
+ sessionStreamId = ms.lastId;
62
+ logger = pino({ level }, ms as unknown as pino.DestinationStream);
63
+ } catch {
64
+ sessionStreamId = -1;
65
+ sessionStreamRef = null;
66
+ ms = null;
67
+ logger = pino({ level: "silent" });
68
+ }
69
+ }
70
+
71
+ export function taskLogsDir(stateTaskDir: string): string {
72
+ const stateSegment = `${sep}.pp${sep}state${sep}`;
73
+ const idx = stateTaskDir.lastIndexOf(stateSegment);
74
+ if (idx < 0) return stateTaskDir;
75
+ return stateTaskDir.slice(0, idx) + `${sep}.pp${sep}logs${sep}` + stateTaskDir.slice(idx + stateSegment.length);
76
+ }
77
+
78
+ export function addTaskDestination(stateTaskDir: string): void {
79
+ if (!ms) return;
80
+ try {
81
+ const logsTaskDir = taskLogsDir(stateTaskDir);
82
+ mkdirSync(logsTaskDir, { recursive: true });
83
+ const logFile = join(logsTaskDir, "debug.jsonl");
84
+ taskStreamRef = pino.destination({ dest: logFile, sync: false });
85
+ ms.add({ stream: taskStreamRef, level: logger.level });
86
+ taskStreamId = ms.lastId;
87
+ } catch {
88
+ taskStreamRef = null;
89
+ taskStreamId = -1;
90
+ }
91
+ }
92
+
93
+ export function removeTaskDestination(): void {
94
+ if (!ms || taskStreamId < 0) return;
95
+ ms.remove(taskStreamId);
96
+ safeEndStream(taskStreamRef);
97
+ taskStreamId = -1;
98
+ taskStreamRef = null;
99
+ }
100
+
101
+ export function setLogLevel(level: LogLevel): void {
102
+ if (!isValidLogLevel(level)) return;
103
+ logger.level = level;
104
+ if (ms && sessionStreamId >= 0 && sessionStreamRef) {
105
+ ms.remove(sessionStreamId);
106
+ ms.add({ stream: sessionStreamRef, level });
107
+ sessionStreamId = ms.lastId;
108
+ }
109
+ if (ms && taskStreamId >= 0 && taskStreamRef) {
110
+ ms.remove(taskStreamId);
111
+ ms.add({ stream: taskStreamRef, level });
112
+ taskStreamId = ms.lastId;
113
+ }
114
+ }
115
+
116
+ export function flushLogs(): void {
117
+ if (ms) {
118
+ try { ms.flushSync(); } catch {}
119
+ }
120
+ }
121
+
122
+ function cleanOldSessionLogs(logsDir: string, maxAgeDays: number): void {
123
+ try {
124
+ if (!existsSync(logsDir)) return;
125
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
126
+ for (const file of readdirSync(logsDir)) {
127
+ if (!file.startsWith("session-") || !file.endsWith(".jsonl")) continue;
128
+ const filePath = join(logsDir, file);
129
+ try {
130
+ const stat = statSync(filePath);
131
+ if (stat.mtimeMs < cutoff) {
132
+ unlinkSync(filePath);
133
+ }
134
+ } catch {}
135
+ }
136
+ } catch {}
137
+ }
138
+
139
+ export function resetLogger(): void {
140
+ removeTaskDestination();
141
+ if (ms && sessionStreamId >= 0) {
142
+ ms.remove(sessionStreamId);
143
+ safeEndStream(sessionStreamRef);
144
+ }
145
+ sessionStreamId = -1;
146
+ sessionStreamRef = null;
147
+ ms = null;
148
+ logger = pino({ level: "silent" });
149
+ }
150
+
151
+ export function getLogger(): pino.Logger {
152
+ return logger;
153
+ }
@@ -0,0 +1,238 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("./log.js", () => ({
4
+ getLogger: () => ({
5
+ debug: vi.fn(),
6
+ }),
7
+ }));
8
+
9
+ import {
10
+ getAllAliases,
11
+ getModelFamilies,
12
+ getModelInfo,
13
+ resolveModel,
14
+ updateRegistryFromAvailableModels,
15
+ } from "./model-registry.js";
16
+
17
+ describe("model-registry", () => {
18
+ beforeEach(() => {
19
+ updateRegistryFromAvailableModels([]);
20
+ });
21
+
22
+ it("resolveModel resolves known aliases", () => {
23
+ expect(resolveModel("anthropic/claude-sonnet-latest")).toBe("anthropic/claude-sonnet-4-6");
24
+ expect(resolveModel("openai/gpt-mini-latest")).toBe("openai/gpt-5.4-mini");
25
+ });
26
+
27
+ it("resolveModel passes through unknown aliases", () => {
28
+ expect(resolveModel("custom/provider-model")).toBe("custom/provider-model");
29
+ });
30
+
31
+ it("resolveModel resolves flant aliases", () => {
32
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-6");
33
+ expect(resolveModel("pp-flant-openai/gemini-pro-latest")).toBe("pp-flant-openai/gemini-3.1-pro");
34
+ });
35
+
36
+ it("getModelInfo detects all configured families", () => {
37
+ expect(getModelInfo("anthropic/claude-opus-4-6")).toMatchObject({ vendor: "anthropic", family: "opus", tier: "smart" });
38
+ expect(getModelInfo("anthropic/claude-sonnet-4-6")).toMatchObject({ vendor: "anthropic", family: "sonnet", tier: "regular" });
39
+ expect(getModelInfo("anthropic/claude-haiku-3-5")).toMatchObject({ vendor: "anthropic", family: "haiku", tier: "stupid" });
40
+ expect(getModelInfo("openai/gpt-5.4")).toMatchObject({ vendor: "openai", family: "gpt", tier: "regular" });
41
+ expect(getModelInfo("openai/gpt-5.4-mini")).toMatchObject({ vendor: "openai", family: "gpt-mini", tier: "stupid" });
42
+ expect(getModelInfo("google/gemini-3.1-pro")).toMatchObject({ vendor: "google", family: "gemini-pro", tier: "regular" });
43
+ expect(getModelInfo("google/gemini-3.1-flash")).toMatchObject({ vendor: "google", family: "gemini-flash", tier: "stupid" });
44
+ expect(getModelInfo("deepseek/deepseek-v3")).toMatchObject({ vendor: "deepseek", family: "deepseek", tier: "regular" });
45
+ expect(getModelInfo("x-ai/grok-4")).toMatchObject({ vendor: "xai", family: "grok", tier: "regular" });
46
+ expect(getModelInfo("qwen/qwen-coder-3")).toMatchObject({ vendor: "qwen", family: "qwen", tier: "regular" });
47
+ });
48
+
49
+ it("getModelInfo returns unknown for unknown models", () => {
50
+ expect(getModelInfo("unknown/provider-model")).toEqual({
51
+ vendor: "unknown",
52
+ family: "unknown",
53
+ tier: "unknown",
54
+ displayName: "unknown/provider-model",
55
+ });
56
+ });
57
+
58
+ it("getModelInfo resolves alias before family detection", () => {
59
+ expect(getModelInfo("openai/gpt-latest")).toMatchObject({
60
+ vendor: "openai",
61
+ family: "gpt",
62
+ tier: "regular",
63
+ });
64
+ });
65
+
66
+ it("getModelInfo recognizes flant provider models", () => {
67
+ expect(getModelInfo("pp-flant-anthropic/claude-sonnet-4-6")).toMatchObject({
68
+ vendor: "anthropic",
69
+ family: "sonnet",
70
+ tier: "regular",
71
+ });
72
+ expect(getModelInfo("pp-flant-openai/gpt-5.4-mini")).toMatchObject({
73
+ vendor: "openai",
74
+ family: "gpt-mini",
75
+ tier: "stupid",
76
+ });
77
+ expect(getModelInfo("pp-flant-openai/gemini-3.1-pro")).toMatchObject({
78
+ vendor: "google",
79
+ family: "gemini-pro",
80
+ tier: "regular",
81
+ });
82
+ });
83
+
84
+ it("getModelInfo matches alternate gpt-mini naming", () => {
85
+ expect(getModelInfo("openai/gpt-4.1-mini")).toMatchObject({
86
+ vendor: "openai",
87
+ family: "gpt-mini",
88
+ tier: "stupid",
89
+ });
90
+ });
91
+
92
+ it("getModelInfo matches alternate gemini pro and flash naming", () => {
93
+ expect(getModelInfo("google/gemini-2.0-pro-preview")).toMatchObject({
94
+ vendor: "google",
95
+ family: "gemini-pro",
96
+ tier: "regular",
97
+ });
98
+ expect(getModelInfo("google/gemini-2.0-flash-exp")).toMatchObject({
99
+ vendor: "google",
100
+ family: "gemini-flash",
101
+ tier: "stupid",
102
+ });
103
+ });
104
+
105
+ it("getModelInfo detects flant deepseek grok and qwen models", () => {
106
+ expect(getModelInfo("pp-flant-openai/deepseek-v3")).toMatchObject({ vendor: "deepseek", family: "deepseek", tier: "regular" });
107
+ expect(getModelInfo("pp-flant-openai/grok-4")).toMatchObject({ vendor: "xai", family: "grok", tier: "regular" });
108
+ expect(getModelInfo("pp-flant-openai/qwen-coder-3")).toMatchObject({ vendor: "qwen", family: "qwen", tier: "regular" });
109
+ });
110
+
111
+ it("updateRegistryFromAvailableModels updates aliases from available models", () => {
112
+ updateRegistryFromAvailableModels([
113
+ "openai/gpt-5.6",
114
+ "pp-flant-openai/gpt-5.6-mini",
115
+ "claude-opus-4-7",
116
+ ]);
117
+
118
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.6");
119
+ expect(resolveModel("pp-flant-openai/gpt-mini-latest")).toBe("pp-flant-openai/gpt-5.6-mini");
120
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-7");
121
+ });
122
+
123
+ it("updateRegistryFromAvailableModels picks latest version", () => {
124
+ updateRegistryFromAvailableModels([
125
+ "openai/gpt-5.9",
126
+ "openai/gpt-5.10",
127
+ "openai/gpt-5.10.2",
128
+ "openai/gpt-5.8",
129
+ ]);
130
+
131
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.10.2");
132
+ });
133
+
134
+ it("updateRegistryFromAvailableModels normalizes bare model ids", () => {
135
+ updateRegistryFromAvailableModels([
136
+ "claude-sonnet-4-7",
137
+ "gpt-5-6",
138
+ "gemini-3-2-pro",
139
+ ]);
140
+
141
+ expect(resolveModel("pp-flant-anthropic/claude-sonnet-latest")).toBe("pp-flant-anthropic/claude-sonnet-4-7");
142
+ expect(resolveModel("pp-flant-openai/gpt-latest")).toBe("pp-flant-openai/gpt-5-6");
143
+ expect(resolveModel("pp-flant-openai/gemini-pro-latest")).toBe("pp-flant-openai/gemini-3-2-pro");
144
+ });
145
+
146
+ it("updateRegistryFromAvailableModels deduplicates repeated models", () => {
147
+ updateRegistryFromAvailableModels([
148
+ "openai/gpt-5.6",
149
+ "openai/gpt-5.6",
150
+ "openai/gpt-5.6",
151
+ ]);
152
+
153
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.6");
154
+ });
155
+
156
+ it("updateRegistryFromAvailableModels keeps defaults for missing families", () => {
157
+ updateRegistryFromAvailableModels(["openai/gpt-9.1"]);
158
+
159
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-9.1");
160
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-4-6");
161
+ });
162
+
163
+ it("updateRegistryFromAvailableModels ignores aliases ending with -latest", () => {
164
+ updateRegistryFromAvailableModels([
165
+ "openai/gpt-5.4",
166
+ "openai/gpt-latest",
167
+ "pp-flant-anthropic/claude-opus-latest",
168
+ ]);
169
+
170
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
171
+ expect(resolveModel("pp-flant-anthropic/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-6");
172
+ });
173
+
174
+ it("updateRegistryFromAvailableModels handles empty input", () => {
175
+ updateRegistryFromAvailableModels(["openai/gpt-9.9"]);
176
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-9.9");
177
+
178
+ updateRegistryFromAvailableModels([]);
179
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
180
+ });
181
+
182
+ it("updateRegistryFromAvailableModels ignores unknown bare ids", () => {
183
+ updateRegistryFromAvailableModels(["custom-model-1", "another-custom-model"]);
184
+
185
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
186
+ expect(resolveModel("anthropic/claude-opus-latest")).toBe("anthropic/claude-opus-4-6");
187
+ });
188
+
189
+ it("updateRegistryFromAvailableModels chooses highest among flant openai versions", () => {
190
+ updateRegistryFromAvailableModels([
191
+ "pp-flant-openai/gpt-5.4",
192
+ "pp-flant-openai/gpt-5.6",
193
+ "pp-flant-openai/gpt-5.5",
194
+ ]);
195
+
196
+ expect(resolveModel("pp-flant-openai/gpt-latest")).toBe("pp-flant-openai/gpt-5.6");
197
+ });
198
+
199
+ it("getAllAliases returns a copy", () => {
200
+ const aliases = getAllAliases();
201
+ aliases["openai/gpt-latest"] = "openai/gpt-0.0";
202
+
203
+ expect(resolveModel("openai/gpt-latest")).toBe("openai/gpt-5.4");
204
+ });
205
+
206
+ it("getAllAliases contains known defaults", () => {
207
+ const aliases = getAllAliases();
208
+
209
+ expect(aliases["anthropic/claude-opus-latest"]).toBe("anthropic/claude-opus-4-6");
210
+ expect(aliases["pp-flant-openai/grok-latest"]).toBe("pp-flant-openai/grok-4");
211
+ });
212
+
213
+ it("getModelFamilies returns all family definitions", () => {
214
+ const families = getModelFamilies();
215
+ expect(families).toHaveLength(10);
216
+ expect(families.map((f) => f.family).sort()).toEqual([
217
+ "deepseek",
218
+ "gemini-flash",
219
+ "gemini-pro",
220
+ "gpt",
221
+ "gpt-mini",
222
+ "grok",
223
+ "haiku",
224
+ "opus",
225
+ "qwen",
226
+ "sonnet",
227
+ ]);
228
+ expect(families.find((f) => f.family === "opus")?.aliases).toContain("pp-flant-anthropic/claude-opus-latest");
229
+ expect(families.find((f) => f.family === "gpt")?.aliases).toContain("pp-flant-openai/gpt-latest");
230
+ });
231
+
232
+ it("getModelFamilies exposes vendor and tier per family", () => {
233
+ const families = getModelFamilies();
234
+
235
+ expect(families.find((f) => f.family === "haiku")).toMatchObject({ vendor: "anthropic", tier: "stupid" });
236
+ expect(families.find((f) => f.family === "gemini-pro")).toMatchObject({ vendor: "google", tier: "regular" });
237
+ });
238
+ });