@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.
Files changed (59) hide show
  1. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  2. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  5. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  7. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  8. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  9. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  10. package/3p/pi-subagents/src/index.ts +3 -38
  11. package/3p/pi-subagents/src/types.ts +4 -0
  12. package/extensions/orchestrator/agents/advisor.ts +35 -0
  13. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  14. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  16. package/extensions/orchestrator/agents/constraints.ts +3 -0
  17. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  18. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  19. package/extensions/orchestrator/agents/planner.ts +9 -8
  20. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  21. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  22. package/extensions/orchestrator/agents/task.ts +6 -20
  23. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  24. package/extensions/orchestrator/command-handlers.ts +1 -1
  25. package/extensions/orchestrator/config.ts +5 -2
  26. package/extensions/orchestrator/context.test.ts +54 -0
  27. package/extensions/orchestrator/context.ts +65 -2
  28. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  29. package/extensions/orchestrator/event-handlers.ts +176 -43
  30. package/extensions/orchestrator/flant-infra.test.ts +25 -0
  31. package/extensions/orchestrator/flant-infra.ts +91 -0
  32. package/extensions/orchestrator/index.ts +1 -1
  33. package/extensions/orchestrator/integration.test.ts +107 -12
  34. package/extensions/orchestrator/messages.test.ts +30 -0
  35. package/extensions/orchestrator/messages.ts +6 -0
  36. package/extensions/orchestrator/model-registry.test.ts +48 -1
  37. package/extensions/orchestrator/model-registry.ts +43 -1
  38. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  39. package/extensions/orchestrator/orchestrator.ts +151 -2
  40. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  41. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  42. package/extensions/orchestrator/phases/implementation.ts +4 -6
  43. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  44. package/extensions/orchestrator/phases/planning.ts +11 -4
  45. package/extensions/orchestrator/phases/review-task.ts +1 -4
  46. package/extensions/orchestrator/phases/review.test.ts +8 -0
  47. package/extensions/orchestrator/phases/review.ts +4 -3
  48. package/extensions/orchestrator/plannotator.ts +9 -6
  49. package/extensions/orchestrator/pp-menu.ts +168 -54
  50. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  51. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  52. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  53. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  54. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  55. package/extensions/orchestrator/test-helpers.ts +4 -1
  56. package/extensions/orchestrator/usage-tracker.ts +5 -1
  57. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  58. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  59. 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("let the user review and advance it via the /pp menu");
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
- it("turn_end with error retries with exponential backoff", async () => {
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: "rate limited", content: [] } }, ctx);
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 stops retrying after max retries", async () => {
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: "api down", content: [] } }, ctx);
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("successful turn resets error count", async () => {
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: "once", content: [] } }, ctx);
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(0);
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
- const resolved = aliasMap[aliasOrId] ?? aliasOrId;
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 {