@ilya-lesikov/pi-pi 0.7.0 → 0.8.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.
- package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +5 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -2
- package/extensions/orchestrator/event-handlers.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +176 -43
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +107 -12
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +151 -2
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +8 -0
- package/extensions/orchestrator/phases/review.ts +4 -3
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.ts +168 -54
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
|
@@ -70,6 +70,9 @@ vi.mock("./config.js", async (importOriginal) => {
|
|
|
70
70
|
explore: { model: "test/explore", thinking: "low" },
|
|
71
71
|
librarian: { model: "test/librarian", thinking: "medium" },
|
|
72
72
|
task: { model: "test/task", thinking: "medium" },
|
|
73
|
+
advisor: { model: "test/advisor", thinking: "high" },
|
|
74
|
+
"deep-debugger": { model: "test/deep-debugger", thinking: "high" },
|
|
75
|
+
reviewer: { model: "test/reviewer", thinking: "high" },
|
|
73
76
|
},
|
|
74
77
|
presetGroups: {
|
|
75
78
|
planners: {
|
|
@@ -223,6 +226,9 @@ function makeConfig() {
|
|
|
223
226
|
explore: { model: "test/explore", thinking: "low" },
|
|
224
227
|
librarian: { model: "test/librarian", thinking: "medium" },
|
|
225
228
|
task: { model: "test/task", thinking: "medium" },
|
|
229
|
+
advisor: { model: "test/advisor", thinking: "high" },
|
|
230
|
+
"deep-debugger": { model: "test/deep-debugger", thinking: "high" },
|
|
231
|
+
reviewer: { model: "test/reviewer", thinking: "high" },
|
|
226
232
|
},
|
|
227
233
|
presetGroups: {
|
|
228
234
|
planners: {
|
|
@@ -672,7 +678,7 @@ describe("review cycle lifecycle", () => {
|
|
|
672
678
|
await new Promise((r) => setTimeout(r, 10));
|
|
673
679
|
|
|
674
680
|
expectReviewAuto(menu);
|
|
675
|
-
expectReviewOnMyOwn(menu);
|
|
681
|
+
expectReviewOnMyOwn(menu, "Skip markers");
|
|
676
682
|
const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
677
683
|
|
|
678
684
|
expect(result.content[0].text).toContain("continue");
|
|
@@ -1355,7 +1361,7 @@ describe("edge cases and regressions", () => {
|
|
|
1355
1361
|
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
1356
1362
|
await new Promise((r) => setTimeout(r, 10));
|
|
1357
1363
|
|
|
1358
|
-
expectReviewOnMyOwn(menu);
|
|
1364
|
+
expectReviewOnMyOwn(menu, "Skip markers");
|
|
1359
1365
|
const result = await ppPhaseComplete.execute("call-3", { summary: "partial work" }, undefined, undefined, ctx);
|
|
1360
1366
|
|
|
1361
1367
|
expect(result.content[0].text).toContain("continue");
|
|
@@ -1402,6 +1408,47 @@ describe("edge cases and regressions", () => {
|
|
|
1402
1408
|
expect(orchestrator.active!.state.step).toBe("synthesize");
|
|
1403
1409
|
});
|
|
1404
1410
|
|
|
1411
|
+
it("editor-review Done returns AI_REVIEW marker instructions in implement phase", async () => {
|
|
1412
|
+
const cwd = makeTempDir();
|
|
1413
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
1414
|
+
const ctx = makeCtx();
|
|
1415
|
+
|
|
1416
|
+
await orchestrator.startTask(ctx as any, "implement", "Editor review test");
|
|
1417
|
+
const taskDir = orchestrator.active!.dir;
|
|
1418
|
+
writeFileSync(join(taskDir, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
|
|
1419
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
1420
|
+
expectBrainstormToPlan(menu);
|
|
1421
|
+
const ppPhaseComplete = getTool(pi, "pp_phase_complete");
|
|
1422
|
+
await ppPhaseComplete.execute("call-1", { summary: "done" }, undefined, undefined, ctx);
|
|
1423
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1424
|
+
|
|
1425
|
+
emitSubagentCreated(pi, "planner-1", "Planner (test)");
|
|
1426
|
+
const plansDir = join(taskDir, "plans");
|
|
1427
|
+
mkdirSync(plansDir, { recursive: true });
|
|
1428
|
+
writeFileSync(
|
|
1429
|
+
join(plansDir, `${Math.floor(Date.now() / 1000)}_test.md`),
|
|
1430
|
+
makeValidPlan(["- [ ] P1. Planner draft item — Done when: planner output exists"]),
|
|
1431
|
+
"utf-8",
|
|
1432
|
+
);
|
|
1433
|
+
emitSubagentCompleted(pi, "planner-1", "Planner (test)");
|
|
1434
|
+
writeFileSync(
|
|
1435
|
+
join(plansDir, `${Math.floor(Date.now() / 1000) + 1}_synthesized.md`),
|
|
1436
|
+
makeValidPlan(["- [ ] P1. Todo item — Done when: item intentionally remains unchecked"]),
|
|
1437
|
+
"utf-8",
|
|
1438
|
+
);
|
|
1439
|
+
expectPlanToImplement(menu);
|
|
1440
|
+
await ppPhaseComplete.execute("call-2", { summary: "plan done" }, undefined, undefined, ctx);
|
|
1441
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1442
|
+
|
|
1443
|
+
expectReviewOnMyOwn(menu, "Done");
|
|
1444
|
+
const result = await ppPhaseComplete.execute("call-3", { summary: "implemented" }, undefined, undefined, ctx);
|
|
1445
|
+
|
|
1446
|
+
expect(result.content[0].text).toContain("AI_REVIEW:");
|
|
1447
|
+
expect(result.content[0].text).toContain("CHANGED files");
|
|
1448
|
+
expect(orchestrator.active!.state.phase).toBe("implement");
|
|
1449
|
+
expect(orchestrator.active!.state.step).toBe("llm_work");
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1405
1452
|
it("generic description task does not auto-trigger agent", async () => {
|
|
1406
1453
|
const cwd = makeTempDir();
|
|
1407
1454
|
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
@@ -2079,7 +2126,7 @@ describe("task modes and quick task", () => {
|
|
|
2079
2126
|
expect(prompt).toContain("<principles>");
|
|
2080
2127
|
expect(prompt).toContain("<tools>");
|
|
2081
2128
|
expect(prompt).toContain("<task>");
|
|
2082
|
-
expect(prompt).toContain("
|
|
2129
|
+
expect(prompt).toContain("call pp_phase_complete");
|
|
2083
2130
|
expect(prompt).not.toContain("HARNESS_BASE_PROMPT");
|
|
2084
2131
|
expect(prompt).toContain(`Working directory: ${cwd}.`);
|
|
2085
2132
|
expect(prompt).toMatch(/Current month: \d{4}-\d{2}\./);
|
|
@@ -3240,7 +3287,11 @@ describe("tool blocking", () => {
|
|
|
3240
3287
|
});
|
|
3241
3288
|
|
|
3242
3289
|
describe("error retry", () => {
|
|
3243
|
-
|
|
3290
|
+
// Non-SDK-retryable error messages exercise pi-pi's OWN retry path. SDK-
|
|
3291
|
+
// retryable errors (rate limit, overloaded, 5xx, stream-ended, timeout, etc.)
|
|
3292
|
+
// are now deferred to the SDK's own auto-retry (see the defer test below), so
|
|
3293
|
+
// these use a message the SDK does NOT retry.
|
|
3294
|
+
it("turn_end with a non-SDK-retryable error uses pi-pi's idle-gated retry", async () => {
|
|
3244
3295
|
vi.useFakeTimers();
|
|
3245
3296
|
const cwd = makeTempDir();
|
|
3246
3297
|
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
@@ -3249,7 +3300,7 @@ describe("error retry", () => {
|
|
|
3249
3300
|
await orchestrator.startTask(ctx as any, "implement", "retry test");
|
|
3250
3301
|
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3251
3302
|
|
|
3252
|
-
await turnEnd({ message: { stopReason: "error", errorMessage: "
|
|
3303
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
|
|
3253
3304
|
|
|
3254
3305
|
expect(orchestrator.errorRetryCount).toBe(1);
|
|
3255
3306
|
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Retrying in 2s"), "warning");
|
|
@@ -3259,7 +3310,24 @@ describe("error retry", () => {
|
|
|
3259
3310
|
vi.useRealTimers();
|
|
3260
3311
|
});
|
|
3261
3312
|
|
|
3262
|
-
it("turn_end
|
|
3313
|
+
it("turn_end defers SDK-retryable errors to the SDK (no pi-pi retry)", async () => {
|
|
3314
|
+
const cwd = makeTempDir();
|
|
3315
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3316
|
+
const ctx = makeCtx();
|
|
3317
|
+
|
|
3318
|
+
await orchestrator.startTask(ctx as any, "implement", "sdk defer test");
|
|
3319
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3320
|
+
|
|
3321
|
+
// "stream ended before message_stop" is SDK-retryable — pi-pi must NOT run
|
|
3322
|
+
// its own retry (which would race the SDK's continue()).
|
|
3323
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "Anthropic stream ended before message_stop", content: [] } }, ctx);
|
|
3324
|
+
|
|
3325
|
+
expect(orchestrator.errorRetryCount).toBe(0);
|
|
3326
|
+
expect(orchestrator.pendingRetryTimer).toBeNull();
|
|
3327
|
+
expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
|
|
3328
|
+
});
|
|
3329
|
+
|
|
3330
|
+
it("turn_end stops retrying after max retries (non-SDK-retryable)", async () => {
|
|
3263
3331
|
vi.useFakeTimers();
|
|
3264
3332
|
const cwd = makeTempDir();
|
|
3265
3333
|
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
@@ -3269,27 +3337,54 @@ describe("error retry", () => {
|
|
|
3269
3337
|
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3270
3338
|
|
|
3271
3339
|
for (let i = 0; i < 6; i++) {
|
|
3272
|
-
await turnEnd({ message: { stopReason: "error", errorMessage: "
|
|
3340
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
|
|
3273
3341
|
}
|
|
3274
3342
|
|
|
3275
|
-
expect(orchestrator.errorRetryCount).toBe(0);
|
|
3276
3343
|
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Stopping auto-retry"), "error");
|
|
3344
|
+
expect(orchestrator.errorNudgeHalted).toBe(true);
|
|
3345
|
+
|
|
3346
|
+
// Once halted, further error turns must NOT arm another retry (this is the
|
|
3347
|
+
// guard against the unbounded-nudge bug).
|
|
3348
|
+
ctx.ui.notify.mockClear();
|
|
3349
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
|
|
3350
|
+
expect(ctx.ui.notify).not.toHaveBeenCalledWith(expect.stringContaining("Retrying in"), "warning");
|
|
3277
3351
|
vi.useRealTimers();
|
|
3278
3352
|
});
|
|
3279
3353
|
|
|
3280
|
-
it("
|
|
3354
|
+
it("a benign turn does NOT reset the error count (cap must accumulate)", async () => {
|
|
3281
3355
|
const cwd = makeTempDir();
|
|
3282
3356
|
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3283
3357
|
const ctx = makeCtx();
|
|
3284
3358
|
|
|
3285
|
-
await orchestrator.startTask(ctx as any, "implement", "retry reset test");
|
|
3359
|
+
await orchestrator.startTask(ctx as any, "implement", "retry no-reset test");
|
|
3286
3360
|
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3287
3361
|
|
|
3288
|
-
await turnEnd({ message: { stopReason: "error", errorMessage: "
|
|
3362
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
|
|
3289
3363
|
expect(orchestrator.errorRetryCount).toBe(1);
|
|
3290
3364
|
|
|
3365
|
+
// A benign (non-error) turn must NOT zero the counter — otherwise a harmless
|
|
3366
|
+
// nudge-induced reply would defeat the cap and allow unbounded error nudges.
|
|
3291
3367
|
await turnEnd({ message: { stopReason: "stop", content: [] } }, ctx);
|
|
3292
|
-
expect(orchestrator.errorRetryCount).toBe(
|
|
3368
|
+
expect(orchestrator.errorRetryCount).toBe(1);
|
|
3369
|
+
});
|
|
3370
|
+
|
|
3371
|
+
it("genuine user re-engagement resets the error count and halt", async () => {
|
|
3372
|
+
const cwd = makeTempDir();
|
|
3373
|
+
const { pi, orchestrator } = await setupOrchestrator(cwd);
|
|
3374
|
+
const ctx = makeCtx();
|
|
3375
|
+
|
|
3376
|
+
await orchestrator.startTask(ctx as any, "implement", "retry reengage test");
|
|
3377
|
+
const turnEnd = pi._handlers.get("turn_end")!;
|
|
3378
|
+
|
|
3379
|
+
await turnEnd({ message: { stopReason: "error", errorMessage: "invalid tool arguments", content: [] } }, ctx);
|
|
3380
|
+
orchestrator.errorNudgeHalted = true;
|
|
3381
|
+
|
|
3382
|
+
const beforeStart = pi._handlers.get("before_agent_start");
|
|
3383
|
+
if (beforeStart) {
|
|
3384
|
+
await beforeStart({ prompt: "user typed something new" }, ctx);
|
|
3385
|
+
expect(orchestrator.errorRetryCount).toBe(0);
|
|
3386
|
+
expect(orchestrator.errorNudgeHalted).toBe(false);
|
|
3387
|
+
}
|
|
3293
3388
|
});
|
|
3294
3389
|
|
|
3295
3390
|
it("empty turn triggers continuation nudge", async () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { advanceBanner } from "./messages.js";
|
|
3
|
+
|
|
4
|
+
describe("advanceBanner", () => {
|
|
5
|
+
it("starts with [PI-PI] so the controller-injection guard still matches", () => {
|
|
6
|
+
const out = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
|
|
7
|
+
expect(out.startsWith("[PI-PI]")).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("renders separators around the content for visual distinctness", () => {
|
|
11
|
+
const out = advanceBanner("[PI-PI] hello");
|
|
12
|
+
const lines = out.split("\n");
|
|
13
|
+
expect(lines[0]).toBe("[PI-PI]");
|
|
14
|
+
expect(lines[1]).toMatch(/^─+$/);
|
|
15
|
+
expect(lines[2]).toBe("hello");
|
|
16
|
+
expect(lines[3]).toMatch(/^─+$/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("does not double up the [PI-PI] prefix when the body already has one", () => {
|
|
20
|
+
const out = advanceBanner("[PI-PI] hello");
|
|
21
|
+
expect(out.match(/\[PI-PI\]/g)?.length).toBe(1);
|
|
22
|
+
expect(out).not.toContain("[PI-PI] [PI-PI]");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("adds the prefix when the body lacks one", () => {
|
|
26
|
+
const out = advanceBanner("plain body");
|
|
27
|
+
expect(out.startsWith("[PI-PI]")).toBe(true);
|
|
28
|
+
expect(out).toContain("plain body");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const BANNER_SEPARATOR = "─".repeat(48);
|
|
2
|
+
|
|
3
|
+
export function advanceBanner(body: string): string {
|
|
4
|
+
const content = body.startsWith("[PI-PI]") ? body.slice("[PI-PI]".length).trimStart() : body;
|
|
5
|
+
return `[PI-PI]\n${BANNER_SEPARATOR}\n${content}\n${BANNER_SEPARATOR}`;
|
|
6
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("./log.js", () => ({
|
|
4
4
|
getLogger: () => ({
|
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
getAllAliases,
|
|
12
12
|
getModelFamilies,
|
|
13
13
|
getModelInfo,
|
|
14
|
+
isSubscriptionFallbackActive,
|
|
14
15
|
resolveModel,
|
|
16
|
+
setSubscriptionFallbackActive,
|
|
17
|
+
toNonSubSpec,
|
|
15
18
|
updateRegistryFromAvailableModels,
|
|
16
19
|
} from "./model-registry.js";
|
|
17
20
|
|
|
@@ -299,4 +302,48 @@ describe("model-registry", () => {
|
|
|
299
302
|
expect(findLatestFamilyMatch("anthropic/claude-opus-latest", [])).toBeNull();
|
|
300
303
|
});
|
|
301
304
|
});
|
|
305
|
+
|
|
306
|
+
describe("subscription fallback rewrite", () => {
|
|
307
|
+
beforeEach(() => setSubscriptionFallbackActive(false));
|
|
308
|
+
afterEach(() => setSubscriptionFallbackActive(false));
|
|
309
|
+
|
|
310
|
+
it("toNonSubSpec rewrites provider-prefixed sub specs", () => {
|
|
311
|
+
expect(toNonSubSpec("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("toNonSubSpec rewrites bare sub/ ids", () => {
|
|
315
|
+
expect(toNonSubSpec("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("toNonSubSpec leaves non-subscription specs unchanged", () => {
|
|
319
|
+
expect(toNonSubSpec("pp-flant-anthropic/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
320
|
+
expect(toNonSubSpec("openai/gpt-5.4")).toBe("openai/gpt-5.4");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("resolveModel leaves sub specs unchanged while fallback inactive", () => {
|
|
324
|
+
expect(isSubscriptionFallbackActive()).toBe(false);
|
|
325
|
+
expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("resolveModel rewrites sub specs to non-sub while fallback active", () => {
|
|
329
|
+
setSubscriptionFallbackActive(true);
|
|
330
|
+
expect(isSubscriptionFallbackActive()).toBe(true);
|
|
331
|
+
expect(resolveModel("pp-flant-anthropic-sub/sub/claude-opus-4-8")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
332
|
+
expect(resolveModel("sub/claude-haiku-4-5")).toBe("pp-flant-anthropic/claude-haiku-4-5");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("resolveModel does not touch non-sub specs while fallback active", () => {
|
|
336
|
+
setSubscriptionFallbackActive(true);
|
|
337
|
+
expect(resolveModel("pp-flant-openai/gpt-5-4")).toBe("pp-flant-openai/gpt-5-4");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("resolveModel rewrites a subscription alias after registry update while fallback active", () => {
|
|
341
|
+
updateRegistryFromAvailableModels([
|
|
342
|
+
"pp-flant-anthropic-sub/sub/claude-opus-4-7",
|
|
343
|
+
"pp-flant-anthropic-sub/sub/claude-opus-4-8",
|
|
344
|
+
]);
|
|
345
|
+
setSubscriptionFallbackActive(true);
|
|
346
|
+
expect(resolveModel("pp-flant-anthropic-sub/claude-opus-latest")).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
302
349
|
});
|
|
@@ -207,12 +207,54 @@ function findFamily(modelId: string): ModelFamilyDefinition | null {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
import { getLogger } from "./log.js";
|
|
210
|
+
import { SUB_MODEL_PREFIX, SUB_PROVIDER } from "./flant-infra.js";
|
|
211
|
+
|
|
212
|
+
// Session-scoped subscription fallback. When active (set after the user accepts
|
|
213
|
+
// a sub-429 fallback), every model spec resolved through resolveModel that is
|
|
214
|
+
// subscription-routed (`pp-flant-anthropic-sub/sub/<m>` or a bare `sub/<m>`) is
|
|
215
|
+
// rewritten to the regular per-token form `pp-flant-anthropic/<m>`. This is the
|
|
216
|
+
// single lever every resolution site funnels through (main phase switchModel,
|
|
217
|
+
// subagent tool_call input.model, planner/reviewer specs, registerAgents), so a
|
|
218
|
+
// one-shot switch cannot be undone by a later phase/subagent re-resolving a
|
|
219
|
+
// `sub/` spec. Cleared when the user switches back or the task ends.
|
|
220
|
+
let subscriptionFallbackActive = false;
|
|
221
|
+
|
|
222
|
+
export function setSubscriptionFallbackActive(active: boolean): void {
|
|
223
|
+
subscriptionFallbackActive = active;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function isSubscriptionFallbackActive(): boolean {
|
|
227
|
+
return subscriptionFallbackActive;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Rewrite a subscription-routed spec to its regular per-token equivalent.
|
|
231
|
+
// Handles both `pp-flant-anthropic-sub/sub/<m>` and a bare `sub/<m>` id.
|
|
232
|
+
// Non-subscription specs pass through unchanged.
|
|
233
|
+
export function toNonSubSpec(spec: string): string {
|
|
234
|
+
if (spec.startsWith(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`)) {
|
|
235
|
+
return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/${SUB_MODEL_PREFIX}`.length)}`;
|
|
236
|
+
}
|
|
237
|
+
if (spec.startsWith(`${SUB_PROVIDER}/`)) {
|
|
238
|
+
return `pp-flant-anthropic/${spec.slice(`${SUB_PROVIDER}/`.length)}`;
|
|
239
|
+
}
|
|
240
|
+
if (spec.startsWith(SUB_MODEL_PREFIX)) {
|
|
241
|
+
return `pp-flant-anthropic/${spec.slice(SUB_MODEL_PREFIX.length)}`;
|
|
242
|
+
}
|
|
243
|
+
return spec;
|
|
244
|
+
}
|
|
210
245
|
|
|
211
246
|
export function resolveModel(aliasOrId: string): string {
|
|
212
|
-
|
|
247
|
+
let resolved = aliasMap[aliasOrId] ?? aliasOrId;
|
|
213
248
|
if (resolved !== aliasOrId) {
|
|
214
249
|
getLogger().debug({ s: "model", alias: aliasOrId, resolved }, "resolved model alias");
|
|
215
250
|
}
|
|
251
|
+
if (subscriptionFallbackActive) {
|
|
252
|
+
const rewritten = toNonSubSpec(resolved);
|
|
253
|
+
if (rewritten !== resolved) {
|
|
254
|
+
getLogger().debug({ s: "model", from: resolved, to: rewritten }, "subscription fallback rewrite");
|
|
255
|
+
resolved = rewritten;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
216
258
|
return resolved;
|
|
217
259
|
}
|
|
218
260
|
|
|
@@ -123,6 +123,119 @@ describe("Orchestrator.safeSendUserMessage", () => {
|
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
describe("Orchestrator.cancelPendingRetry", () => {
|
|
127
|
+
it("clears the pending timer, disarms the ESC interrupt, and resets the counter", () => {
|
|
128
|
+
vi.useFakeTimers();
|
|
129
|
+
const send = vi.fn();
|
|
130
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
131
|
+
const unsub = vi.fn();
|
|
132
|
+
orchestrator.errorRetryCount = 3;
|
|
133
|
+
orchestrator.pendingRetryEscUnsub = unsub;
|
|
134
|
+
orchestrator.pendingRetryTimer = setTimeout(() => send("fired"), 1000) as any;
|
|
135
|
+
|
|
136
|
+
orchestrator.cancelPendingRetry();
|
|
137
|
+
|
|
138
|
+
expect(orchestrator.pendingRetryTimer).toBeNull();
|
|
139
|
+
expect(orchestrator.pendingRetryEscUnsub).toBeNull();
|
|
140
|
+
expect(orchestrator.errorRetryCount).toBe(0);
|
|
141
|
+
expect(unsub).toHaveBeenCalledTimes(1);
|
|
142
|
+
vi.advanceTimersByTime(2000);
|
|
143
|
+
expect(send).not.toHaveBeenCalled();
|
|
144
|
+
vi.useRealTimers();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("Orchestrator.armRetryEscInterrupt", () => {
|
|
149
|
+
function makeCtxWithTerminal() {
|
|
150
|
+
let handler: ((data: string) => any) | null = null;
|
|
151
|
+
const notify = vi.fn();
|
|
152
|
+
const ctx = {
|
|
153
|
+
ui: {
|
|
154
|
+
notify,
|
|
155
|
+
onTerminalInput: (h: (data: string) => any) => {
|
|
156
|
+
handler = h;
|
|
157
|
+
return () => { handler = null; };
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
return { ctx, notify, feed: (data: string) => handler?.(data) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it("cancels the pending retry on a bare ESC only", () => {
|
|
165
|
+
vi.useFakeTimers();
|
|
166
|
+
const orchestrator = new Orchestrator(makePi());
|
|
167
|
+
const { ctx, notify, feed } = makeCtxWithTerminal();
|
|
168
|
+
orchestrator.pendingRetryTimer = setTimeout(() => {}, 10000) as any;
|
|
169
|
+
orchestrator.armRetryEscInterrupt(ctx as any);
|
|
170
|
+
|
|
171
|
+
// Arrow key (ESC-prefixed sequence) must NOT cancel and must NOT be consumed.
|
|
172
|
+
const arrow = feed("\x1b[A");
|
|
173
|
+
expect(arrow).toBeUndefined();
|
|
174
|
+
expect(orchestrator.pendingRetryTimer).not.toBeNull();
|
|
175
|
+
|
|
176
|
+
// Bare ESC cancels and consumes.
|
|
177
|
+
const esc = feed("\x1b");
|
|
178
|
+
expect(esc).toEqual({ consume: true });
|
|
179
|
+
expect(orchestrator.pendingRetryTimer).toBeNull();
|
|
180
|
+
expect(notify).toHaveBeenCalled();
|
|
181
|
+
vi.useRealTimers();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("is a no-op when no retry timer is armed", () => {
|
|
185
|
+
const orchestrator = new Orchestrator(makePi());
|
|
186
|
+
const { ctx, feed } = makeCtxWithTerminal();
|
|
187
|
+
orchestrator.armRetryEscInterrupt(ctx as any);
|
|
188
|
+
expect(feed("\x1b")).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("Orchestrator.sendUserMessageWhenIdle", () => {
|
|
193
|
+
it("sends immediately when idle", () => {
|
|
194
|
+
const send = vi.fn();
|
|
195
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
196
|
+
orchestrator.active = makeActiveTask(null);
|
|
197
|
+
orchestrator.activeTaskToken = 7;
|
|
198
|
+
orchestrator.lastCtx = { isIdle: () => true };
|
|
199
|
+
|
|
200
|
+
orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 7);
|
|
201
|
+
expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("defers while busy, then sends once idle", async () => {
|
|
205
|
+
vi.useFakeTimers();
|
|
206
|
+
const send = vi.fn();
|
|
207
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
208
|
+
orchestrator.active = makeActiveTask(null);
|
|
209
|
+
orchestrator.activeTaskToken = 1;
|
|
210
|
+
let idle = false;
|
|
211
|
+
orchestrator.lastCtx = { isIdle: () => idle };
|
|
212
|
+
|
|
213
|
+
orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
|
|
214
|
+
expect(send).not.toHaveBeenCalled();
|
|
215
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
216
|
+
expect(send).not.toHaveBeenCalled();
|
|
217
|
+
idle = true;
|
|
218
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
219
|
+
expect(send).toHaveBeenCalledWith("[PI-PI] go", { deliverAs: "followUp" });
|
|
220
|
+
vi.useRealTimers();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("drops (does not send) when the task token changes", async () => {
|
|
224
|
+
vi.useFakeTimers();
|
|
225
|
+
const send = vi.fn();
|
|
226
|
+
const orchestrator = new Orchestrator(makePi({ sendUserMessage: send }));
|
|
227
|
+
orchestrator.active = makeActiveTask(null);
|
|
228
|
+
orchestrator.activeTaskToken = 1;
|
|
229
|
+
orchestrator.lastCtx = { isIdle: () => false };
|
|
230
|
+
|
|
231
|
+
orchestrator.sendUserMessageWhenIdle("[PI-PI] go", 1);
|
|
232
|
+
orchestrator.activeTaskToken = 2; // task switched
|
|
233
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
234
|
+
expect(send).not.toHaveBeenCalled();
|
|
235
|
+
vi.useRealTimers();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
126
239
|
describe("Orchestrator.switchModel thinking level", () => {
|
|
127
240
|
function makeCtx() {
|
|
128
241
|
return {
|