@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.2

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 (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +32 -7
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. package/src/web/search/types.ts +6 -1
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from "bun:test";
2
2
  import type { AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
3
3
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
4
+ import * as AIError from "@oh-my-pi/pi-ai/error";
4
5
  import type { TUI } from "@oh-my-pi/pi-tui";
5
6
  import { type } from "arktype";
6
7
  import type { ModelRegistry } from "../../config/model-registry";
@@ -35,6 +36,14 @@ import {
35
36
  type WatchdogConfigDoc,
36
37
  } from "..";
37
38
 
39
+ /** Poll until the drain loop reaches the asserted state — waitForCatchup
40
+ * releases IMMEDIATELY on advisor failure (the primary must never park on a
41
+ * failing advisor), so failure-path tests cannot use it as a settle barrier. */
42
+ async function settleUntil(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
43
+ const deadline = Date.now() + timeoutMs;
44
+ while (!predicate() && Date.now() < deadline) await Bun.sleep(2);
45
+ }
46
+
38
47
  describe("advisor", () => {
39
48
  describe("advisor system prompt", () => {
40
49
  it("forbids concrete claims about tool arguments hidden from the advisor transcript", () => {
@@ -497,6 +506,19 @@ describe("advisor", () => {
497
506
  expect(message.content).toBe(originalContent);
498
507
  });
499
508
 
509
+ it("leaves an authorized Cursor native delete call intact", () => {
510
+ const message = {
511
+ role: "assistant",
512
+ content: [{ type: "toolCall", id: "tc-delete", name: "delete", arguments: { path: "obsolete.txt" } }],
513
+ stopReason: "toolUse",
514
+ } as unknown as AssistantMessage;
515
+ const originalContent = message.content;
516
+
517
+ expect(quarantineAdvisorUnsafeOutput(message, new Set(["advise", "write", "delete"]))).toBeUndefined();
518
+ expect(message.stopReason).toBe("toolUse");
519
+ expect(message.content).toBe(originalContent);
520
+ });
521
+
500
522
  it("sanitizes destructive advise notes even when advise is an allowed tool", () => {
501
523
  const message = {
502
524
  role: "assistant",
@@ -843,6 +865,57 @@ describe("advisor", () => {
843
865
  expect(promptInputs[1]).toContain("second");
844
866
  });
845
867
 
868
+ it("preserves the next user turn when an accepted empty stop is pruned", async () => {
869
+ const promptInputs: string[] = [];
870
+ const agent = makeAgent(promptInputs);
871
+ const messages: AgentMessage[] = [
872
+ { role: "user", content: "synthetic capture", synthetic: true, timestamp: 1 } as AgentMessage,
873
+ ];
874
+ const host: AdvisorRuntimeHost = {
875
+ snapshotMessages: () => messages,
876
+ enqueueAdvice: () => {},
877
+ };
878
+ const runtime = new AdvisorRuntime(agent, host);
879
+
880
+ runtime.onTurnEnd(messages);
881
+ await runtime.waitForCatchup(1000, 1);
882
+
883
+ messages.push({
884
+ role: "assistant",
885
+ content: [],
886
+ api: "mock",
887
+ provider: "mock",
888
+ model: "mock-primary",
889
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
890
+ stopReason: "stop",
891
+ timestamp: 2,
892
+ } as unknown as AgentMessage);
893
+ runtime.onTurnEnd(messages);
894
+ await runtime.waitForCatchup(1000, 1);
895
+
896
+ messages.pop();
897
+ messages.push(
898
+ { role: "user", content: "real user instruction", timestamp: 3 } as AgentMessage,
899
+ {
900
+ role: "assistant",
901
+ content: [{ type: "thinking", thinking: "checking files" }],
902
+ api: "mock",
903
+ provider: "mock",
904
+ model: "mock-primary",
905
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
906
+ stopReason: "toolUse",
907
+ timestamp: 4,
908
+ } as unknown as AgentMessage,
909
+ );
910
+ runtime.onTurnEnd(messages);
911
+ await runtime.waitForCatchup(1000, 1);
912
+
913
+ const nextTurn = promptInputs.at(-1);
914
+ expect(nextTurn).toContain("real user instruction");
915
+ expect(nextTurn?.match(/real user instruction/g)).toHaveLength(1);
916
+ expect(nextTurn?.indexOf("real user instruction")).toBeLessThan(nextTurn?.indexOf("checking files") ?? -1);
917
+ });
918
+
846
919
  it("coalesces late-arriving deltas into the batch after context maintenance", async () => {
847
920
  const promptInputs: string[] = [];
848
921
  const { promise: firstMaintainStarted, resolve: startFirstMaintain } = Promise.withResolvers<void>();
@@ -1535,7 +1608,7 @@ describe("advisor", () => {
1535
1608
  expect(promptInputs[1]).toContain("summary-bbb");
1536
1609
  });
1537
1610
 
1538
- it("triggers a re-prime and full replay when maintainContext returns true", async () => {
1611
+ it("clears advisor context without replaying primary history when maintenance requests recovery", async () => {
1539
1612
  const promptInputs: string[] = [];
1540
1613
  const { promise: firstPromptDone, resolve: finishFirst } = Promise.withResolvers<void>();
1541
1614
  const { promise: secondPromptDone, resolve: finishSecond } = Promise.withResolvers<void>();
@@ -1555,36 +1628,325 @@ describe("advisor", () => {
1555
1628
  state: { messages: [] },
1556
1629
  };
1557
1630
  const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1558
- let shouldRePrime = false;
1631
+ let shouldResetContext = false;
1559
1632
  const host: AdvisorRuntimeHost = {
1560
1633
  snapshotMessages: () => messages,
1561
1634
  enqueueAdvice: () => {},
1562
1635
  maintainContext: async tokens => {
1563
1636
  expect(tokens).toBeGreaterThan(0);
1564
- return shouldRePrime;
1637
+ return shouldResetContext;
1565
1638
  },
1566
1639
  };
1567
1640
  const runtime = new AdvisorRuntime(agent, host);
1568
1641
 
1569
- // First turn: normal incremental prompt.
1570
1642
  runtime.onTurnEnd(messages);
1571
1643
  await firstPromptDone;
1572
1644
  expect(promptInputs).toHaveLength(1);
1573
1645
  expect(promptInputs[0]).toContain("aaa");
1574
1646
  expect(resetCount).toBe(0);
1575
1647
 
1576
- // Second turn: maintainContext returns true → re-prime.
1577
- shouldRePrime = true;
1648
+ shouldResetContext = true;
1578
1649
  messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
1579
1650
  runtime.onTurnEnd(messages);
1580
1651
  await secondPromptDone;
1581
1652
 
1582
- // Full replay includes both aaa and bbb.
1583
1653
  expect(promptInputs).toHaveLength(2);
1584
- expect(promptInputs[1]).toContain("aaa");
1585
1654
  expect(promptInputs[1]).toContain("bbb");
1655
+ expect(promptInputs[1]).not.toContain("aaa");
1656
+ expect(resetCount).toBe(1);
1657
+ });
1658
+
1659
+ it("preserves updates queued while async maintenance resets the advisor context", async () => {
1660
+ const promptInputs: string[] = [];
1661
+ let resetCount = 0;
1662
+ const agent: AdvisorAgent = {
1663
+ prompt: async input => {
1664
+ promptInputs.push(input);
1665
+ },
1666
+ abort: () => {},
1667
+ reset: () => {
1668
+ resetCount++;
1669
+ },
1670
+ state: { messages: [] },
1671
+ };
1672
+ const maintenanceStarted = Promise.withResolvers<void>();
1673
+ const maintenanceFinished = Promise.withResolvers<boolean>();
1674
+ let maintenanceCalls = 0;
1675
+ const messages: AgentMessage[] = [{ role: "user", content: "bbb", timestamp: 1 } as AgentMessage];
1676
+ const host: AdvisorRuntimeHost = {
1677
+ snapshotMessages: () => messages,
1678
+ enqueueAdvice: () => {},
1679
+ maintainContext: async () => {
1680
+ maintenanceCalls++;
1681
+ if (maintenanceCalls !== 1) return false;
1682
+ maintenanceStarted.resolve();
1683
+ return await maintenanceFinished.promise;
1684
+ },
1685
+ };
1686
+ const runtime = new AdvisorRuntime(agent, host);
1687
+
1688
+ runtime.onTurnEnd(messages);
1689
+ await maintenanceStarted.promise;
1690
+ messages.push({ role: "user", content: "ccc", timestamp: 2 } as AgentMessage);
1691
+ runtime.onTurnEnd(messages);
1692
+ maintenanceFinished.resolve(true);
1693
+ await runtime.waitForCatchup(1000, 1);
1694
+
1695
+ expect(promptInputs).toHaveLength(2);
1696
+ expect(promptInputs[0]).toContain("bbb");
1697
+ expect(promptInputs[0]).not.toContain("ccc");
1698
+ expect(promptInputs[1]).toContain("ccc");
1699
+ expect(promptInputs[1]).not.toContain("bbb");
1700
+ expect(resetCount).toBe(1);
1701
+ });
1702
+
1703
+ it("re-expands active primary context when maintenance clears advisor history", async () => {
1704
+ const promptInputs: string[] = [];
1705
+ const agent = makeAgent(promptInputs);
1706
+ const planRule =
1707
+ "Plan mode is active. You MUST remain read-only except for the approved plan file at local://PLAN.md.";
1708
+ const messages: AgentMessage[] = [
1709
+ { role: "user", content: "aaa", timestamp: 1 } as AgentMessage,
1710
+ {
1711
+ role: "custom",
1712
+ customType: "plan-mode-context",
1713
+ content: planRule,
1714
+ display: false,
1715
+ timestamp: 2,
1716
+ } as AgentMessage,
1717
+ ];
1718
+ let shouldResetContext = false;
1719
+ const host: AdvisorRuntimeHost = {
1720
+ snapshotMessages: () => messages,
1721
+ enqueueAdvice: () => {},
1722
+ maintainContext: async () => shouldResetContext,
1723
+ };
1724
+ const runtime = new AdvisorRuntime(agent, host);
1725
+
1726
+ runtime.onTurnEnd(messages);
1727
+ await runtime.waitForCatchup(1000, 1);
1728
+ expect(promptInputs[0]).toContain(planRule);
1729
+
1730
+ shouldResetContext = true;
1731
+ messages.push({ role: "user", content: "bbb", timestamp: 3 } as AgentMessage);
1732
+ messages.push({
1733
+ role: "custom",
1734
+ customType: "plan-mode-context",
1735
+ content: planRule,
1736
+ display: false,
1737
+ timestamp: 4,
1738
+ } as AgentMessage);
1739
+ runtime.onTurnEnd(messages);
1740
+ await runtime.waitForCatchup(1000, 1);
1741
+
1742
+ expect(promptInputs).toHaveLength(2);
1743
+ expect(promptInputs[1]).toContain("bbb");
1744
+ expect(promptInputs[1]).not.toContain("aaa");
1745
+ expect(promptInputs[1]).toContain(planRule);
1746
+ expect(promptInputs[1]).not.toContain("unchanged — still in effect");
1747
+ });
1748
+
1749
+ it("recovers a provider overflow at the current cursor without replaying primary history", async () => {
1750
+ const overflowMessage = "context_length_exceeded: Your input exceeds the context window of this model.";
1751
+ const promptInputs: string[] = [];
1752
+ const state: { messages: AgentMessage[]; error?: string } = {
1753
+ messages: [{ role: "user", content: "existing advisor context", timestamp: 1 } as AgentMessage],
1754
+ };
1755
+ let promptCalls = 0;
1756
+ let resetCount = 0;
1757
+ const agent: AdvisorAgent = {
1758
+ prompt: async input => {
1759
+ promptInputs.push(input);
1760
+ promptCalls++;
1761
+ state.error = promptCalls === 1 ? overflowMessage : undefined;
1762
+ },
1763
+ abort: () => {},
1764
+ reset: () => {
1765
+ resetCount++;
1766
+ state.messages.length = 0;
1767
+ state.error = undefined;
1768
+ },
1769
+ state,
1770
+ };
1771
+ const messages: AgentMessage[] = [
1772
+ { role: "user", content: "ancient-primary-one", timestamp: 1 } as AgentMessage,
1773
+ {
1774
+ role: "assistant",
1775
+ content: [{ type: "text", text: "ancient-primary-two" }],
1776
+ timestamp: 2,
1777
+ } as AgentMessage,
1778
+ ];
1779
+ const host: AdvisorRuntimeHost = {
1780
+ snapshotMessages: () => messages,
1781
+ enqueueAdvice: () => {},
1782
+ };
1783
+ const runtime = new AdvisorRuntime(agent, host, 0);
1784
+ runtime.seedTo(messages.length);
1785
+
1786
+ messages.push({ role: "user", content: "overflowing-current-update", timestamp: 3 } as AgentMessage);
1787
+ runtime.onTurnEnd(messages);
1788
+ await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0);
1789
+
1790
+ expect(promptInputs).toHaveLength(2);
1791
+ for (const input of promptInputs) {
1792
+ expect(input).toContain("overflowing-current-update");
1793
+ expect(input).not.toContain("ancient-primary-one");
1794
+ expect(input).not.toContain("ancient-primary-two");
1795
+ }
1796
+ expect(resetCount).toBe(1);
1797
+
1798
+ messages.push({ role: "user", content: "post-recovery-update", timestamp: 4 } as AgentMessage);
1799
+ runtime.onTurnEnd(messages);
1800
+ await settleUntil(() => promptInputs.length >= 3 && runtime.backlog === 0);
1801
+
1802
+ expect(promptInputs).toHaveLength(3);
1803
+ expect(promptInputs[2]).toContain("post-recovery-update");
1804
+ expect(promptInputs[2]).not.toContain("overflowing-current-update");
1805
+ expect(promptInputs[2]).not.toContain("ancient-primary-one");
1806
+ expect(promptInputs[2]).not.toContain("ancient-primary-two");
1807
+ expect(resetCount).toBe(1);
1808
+ });
1809
+
1810
+ it("classifies structured overflow metadata before rolling back the failed turn", async () => {
1811
+ const promptInputs: string[] = [];
1812
+ const state: { messages: AgentMessage[]; error?: string } = {
1813
+ messages: [{ role: "user", content: "existing advisor context", timestamp: 1 } as AgentMessage],
1814
+ };
1815
+ let promptCalls = 0;
1816
+ let resetCount = 0;
1817
+ const agent: AdvisorAgent = {
1818
+ prompt: async input => {
1819
+ promptInputs.push(input);
1820
+ promptCalls++;
1821
+ if (promptCalls !== 1) {
1822
+ state.error = undefined;
1823
+ return;
1824
+ }
1825
+ state.messages.push({ role: "user", content: input, timestamp: 2 } as AgentMessage);
1826
+ const failure: AssistantMessage = {
1827
+ role: "assistant",
1828
+ content: [],
1829
+ api: "openai-responses",
1830
+ provider: "openai",
1831
+ model: "structured-overflow-model",
1832
+ usage: {
1833
+ input: 1,
1834
+ output: 0,
1835
+ cacheRead: 0,
1836
+ cacheWrite: 0,
1837
+ totalTokens: 1,
1838
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1839
+ },
1840
+ stopReason: "error",
1841
+ errorMessage: "opaque provider rejection",
1842
+ errorStatus: 400,
1843
+ errorId: AIError.create(AIError.Flag.ContextOverflow),
1844
+ timestamp: 3,
1845
+ };
1846
+ state.messages.push(failure);
1847
+ state.error = "opaque provider rejection";
1848
+ },
1849
+ abort: () => {},
1850
+ reset: () => {
1851
+ resetCount++;
1852
+ state.messages.length = 0;
1853
+ state.error = undefined;
1854
+ },
1855
+ rollbackTo: count => {
1856
+ state.messages.length = Math.min(count, state.messages.length);
1857
+ state.error = undefined;
1858
+ },
1859
+ state,
1860
+ };
1861
+ const messages: AgentMessage[] = [{ role: "user", content: "ancient-primary", timestamp: 1 } as AgentMessage];
1862
+ const host: AdvisorRuntimeHost = {
1863
+ snapshotMessages: () => messages,
1864
+ enqueueAdvice: () => {},
1865
+ };
1866
+ const runtime = new AdvisorRuntime(agent, host, 0);
1867
+ runtime.seedTo(messages.length);
1868
+
1869
+ messages.push({ role: "user", content: "structured-current-update", timestamp: 2 } as AgentMessage);
1870
+ runtime.onTurnEnd(messages);
1871
+ await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0);
1872
+
1873
+ expect(promptInputs).toHaveLength(2);
1874
+ for (const input of promptInputs) {
1875
+ expect(input).toContain("structured-current-update");
1876
+ expect(input).not.toContain("ancient-primary");
1877
+ }
1586
1878
  expect(resetCount).toBe(1);
1587
1879
  });
1880
+
1881
+ it("drops only a double-overflowing batch and continues queued and later updates", async () => {
1882
+ const overflowMessage = "context_length_exceeded: Your input exceeds the context window of this model.";
1883
+ const promptInputs: string[] = [];
1884
+ const failures: unknown[] = [];
1885
+ const secondAttemptStarted = Promise.withResolvers<void>();
1886
+ const finishSecondAttempt = Promise.withResolvers<void>();
1887
+ const state: { messages: AgentMessage[]; error?: string } = {
1888
+ messages: [{ role: "user", content: "existing advisor context", timestamp: 1 } as AgentMessage],
1889
+ };
1890
+ let failingAttempts = 0;
1891
+ const agent: AdvisorAgent = {
1892
+ prompt: async input => {
1893
+ promptInputs.push(input);
1894
+ if (!input.includes("first-overflow")) {
1895
+ state.error = undefined;
1896
+ return;
1897
+ }
1898
+ failingAttempts++;
1899
+ if (failingAttempts === 2) {
1900
+ secondAttemptStarted.resolve();
1901
+ await finishSecondAttempt.promise;
1902
+ }
1903
+ state.error = overflowMessage;
1904
+ },
1905
+ abort: () => {},
1906
+ reset: () => {
1907
+ state.messages.length = 0;
1908
+ state.error = undefined;
1909
+ },
1910
+ state,
1911
+ };
1912
+ const messages: AgentMessage[] = [{ role: "user", content: "ancient-history", timestamp: 1 } as AgentMessage];
1913
+ const host: AdvisorRuntimeHost = {
1914
+ snapshotMessages: () => messages,
1915
+ enqueueAdvice: () => {},
1916
+ notifyFailure: error => failures.push(error),
1917
+ };
1918
+ const runtime = new AdvisorRuntime(agent, host, 0);
1919
+ runtime.seedTo(messages.length);
1920
+
1921
+ messages.push({ role: "user", content: "first-overflow", timestamp: 2 } as AgentMessage);
1922
+ runtime.onTurnEnd(messages);
1923
+ await secondAttemptStarted.promise;
1924
+
1925
+ messages.push({ role: "user", content: "queued-small-update", timestamp: 3 } as AgentMessage);
1926
+ runtime.onTurnEnd(messages);
1927
+ finishSecondAttempt.resolve();
1928
+ await settleUntil(() => promptInputs.length >= 3 && runtime.backlog === 0);
1929
+
1930
+ expect(failingAttempts).toBe(2);
1931
+ expect(promptInputs).toHaveLength(3);
1932
+ for (const input of promptInputs.slice(0, 2)) {
1933
+ expect(input).toContain("first-overflow");
1934
+ expect(input).not.toContain("ancient-history");
1935
+ }
1936
+ expect(promptInputs[2]).toContain("queued-small-update");
1937
+ expect(promptInputs[2]).not.toContain("first-overflow");
1938
+ expect(promptInputs[2]).not.toContain("ancient-history");
1939
+ expect(failures).toHaveLength(1);
1940
+ expect(runtime.backlog).toBe(0);
1941
+
1942
+ messages.push({ role: "user", content: "later-small-update", timestamp: 4 } as AgentMessage);
1943
+ runtime.onTurnEnd(messages);
1944
+ await runtime.waitForCatchup(1000, 1);
1945
+
1946
+ expect(promptInputs).toHaveLength(4);
1947
+ expect(promptInputs[3]).toContain("later-small-update");
1948
+ expect(promptInputs[3]).not.toContain("first-overflow");
1949
+ });
1588
1950
  it("tracks backlog and blocks until caught up", async () => {
1589
1951
  const promptInputs: string[] = [];
1590
1952
  const { promise: promptStarted, resolve: startPrompt } = Promise.withResolvers<void>();
@@ -1797,28 +2159,23 @@ describe("advisor", () => {
1797
2159
  expect(failures).toHaveLength(2);
1798
2160
  });
1799
2161
 
1800
- it("treats a clean prompt resolution with state.error as a failed turn (real Agent contract)", async () => {
1801
- // `Agent.#runLoop` catches provider/stream failures internally it resolves
1802
- // `prompt()` cleanly and stores the message on `state.error` (e.g. the
1803
- // OpenRouter ZDR `404 No endpoints available` case from #3635). The runtime
1804
- // must surface that as a failed turn even though the awaited promise did
1805
- // not reject.
2162
+ it("halts permanently on an invalid_request rejection instead of retrying forever", async () => {
2163
+ // The runaway observed live: a provider that refuses the configured
2164
+ // model outright ("not supported ... (code=invalid_request_error)")
2165
+ // failed 351 turns/hour in a shared daemon, rebuilding heavy context
2166
+ // every cycle. One drop cycle must latch the runtime off.
1806
2167
  const promptInputs: string[] = [];
1807
2168
  const failures: unknown[] = [];
1808
- const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
1809
- let shouldFail = true;
1810
2169
  const agent: AdvisorAgent = {
1811
2170
  prompt: async input => {
1812
2171
  promptInputs.push(input);
1813
- state.error = shouldFail
1814
- ? "404 No endpoints available matching your guardrail restrictions and data policy."
1815
- : undefined;
2172
+ throw new Error(
2173
+ "Codex error event: The 'gpt-5.3-codex-spark' model is not supported when using Codex with a ChatGPT account. (code=invalid_request_error)",
2174
+ );
1816
2175
  },
1817
2176
  abort: () => {},
1818
- reset: () => {
1819
- state.error = undefined;
1820
- },
1821
- state,
2177
+ reset: () => {},
2178
+ state: { messages: [] },
1822
2179
  };
1823
2180
  const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
1824
2181
  const host: AdvisorRuntimeHost = {
@@ -1835,25 +2192,405 @@ describe("advisor", () => {
1835
2192
 
1836
2193
  expect(promptInputs).toHaveLength(3);
1837
2194
  expect(failures).toHaveLength(1);
1838
- const failure = failures[0];
1839
- if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
1840
- expect(failure.message).toContain("No endpoints available");
1841
- expect(runtime.backlog).toBe(0);
2195
+ expect(runtime.halted).toBe(true);
1842
2196
 
1843
- shouldFail = false;
2197
+ // New deltas must be ignored while halted — no further prompts.
1844
2198
  messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
1845
2199
  runtime.onTurnEnd(messages);
1846
2200
  await Bun.sleep(0);
1847
- expect(failures).toHaveLength(1);
1848
-
1849
- shouldFail = true;
1850
- messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
1851
- runtime.onTurnEnd(messages);
1852
- await Bun.sleep(0);
1853
- await Bun.sleep(0);
1854
2201
  await Bun.sleep(0);
2202
+ expect(promptInputs).toHaveLength(3);
1855
2203
 
1856
- expect(failures).toHaveLength(2);
2204
+ // The catch-up gate must not park the primary agent on a runtime that
2205
+ // will never drain again: resolve immediately regardless of maxMs.
2206
+ await runtime.waitForCatchup(60_000, 0);
2207
+
2208
+ // Explicit reset (config rebuild, /new) re-enables the runtime.
2209
+ runtime.reset();
2210
+ expect(runtime.halted).toBe(false);
2211
+ });
2212
+
2213
+ it("halts after three transient drop cycles without an intervening success, but not across successes", async () => {
2214
+ const promptInputs: string[] = [];
2215
+ let shouldFail = true;
2216
+ const agent: AdvisorAgent = {
2217
+ prompt: async input => {
2218
+ promptInputs.push(input);
2219
+ if (shouldFail) throw new Error("socket hang up");
2220
+ },
2221
+ abort: () => {},
2222
+ reset: () => {},
2223
+ state: { messages: [] },
2224
+ };
2225
+ const messages: AgentMessage[] = [{ role: "user", content: "t1", timestamp: 1 } as AgentMessage];
2226
+ const host: AdvisorRuntimeHost = {
2227
+ snapshotMessages: () => messages,
2228
+ enqueueAdvice: () => {},
2229
+ notifyFailure: () => {},
2230
+ };
2231
+ const runtime = new AdvisorRuntime(agent, host, 0);
2232
+
2233
+ const runTurn = async (content: string) => {
2234
+ messages.push({ role: "user", content, timestamp: messages.length + 1 } as AgentMessage);
2235
+ runtime.onTurnEnd(messages);
2236
+ await Bun.sleep(0);
2237
+ await Bun.sleep(0);
2238
+ await Bun.sleep(0);
2239
+ };
2240
+
2241
+ // Two failing drop cycles, then a success: the cycle counter resets.
2242
+ await runTurn("f1");
2243
+ await runTurn("f2");
2244
+ expect(runtime.halted).toBe(false);
2245
+ shouldFail = false;
2246
+ await runTurn("ok");
2247
+ expect(runtime.halted).toBe(false);
2248
+
2249
+ // Three CONSECUTIVE drop cycles with no success latch the runtime off.
2250
+ shouldFail = true;
2251
+ await runTurn("f3");
2252
+ await runTurn("f4");
2253
+ expect(runtime.halted).toBe(false);
2254
+ await runTurn("f5");
2255
+ expect(runtime.halted).toBe(true);
2256
+ const promptsAtHalt = promptInputs.length;
2257
+ await runTurn("ignored");
2258
+ expect(promptInputs).toHaveLength(promptsAtHalt);
2259
+ });
2260
+
2261
+ it("never holds the primary agent on the catch-up gate while the advisor is failing", async () => {
2262
+ // CRITICAL contract: a broken advisor (wrong model, dead endpoint)
2263
+ // must not stall the primary agent — not even for one hook. The
2264
+ // onTurnError hook here NEVER resolves, simulating a wedged host
2265
+ // callback; a parked waiter must still be released the moment the
2266
+ // advisor turn fails, and later waits must resolve immediately while
2267
+ // the advisor is mid-failure.
2268
+ const agent: AdvisorAgent = {
2269
+ prompt: async () => {
2270
+ throw new Error("socket hang up");
2271
+ },
2272
+ abort: () => {},
2273
+ reset: () => {},
2274
+ state: { messages: [] },
2275
+ };
2276
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
2277
+ const host: AdvisorRuntimeHost = {
2278
+ snapshotMessages: () => messages,
2279
+ enqueueAdvice: () => {},
2280
+ onTurnError: () => new Promise<undefined>(() => {}),
2281
+ };
2282
+ const runtime = new AdvisorRuntime(agent, host, 60_000);
2283
+
2284
+ runtime.onTurnEnd(messages);
2285
+ const started = performance.now();
2286
+ // Parked with a huge budget: must release on the failure, not the timer.
2287
+ await runtime.waitForCatchup(60_000, 1);
2288
+ expect(performance.now() - started).toBeLessThan(2_000);
2289
+
2290
+ // While the advisor is mid-failure (retry pending), new waits are free.
2291
+ const again = performance.now();
2292
+ await runtime.waitForCatchup(60_000, 1);
2293
+ expect(performance.now() - again).toBeLessThan(100);
2294
+ runtime.dispose();
2295
+ }, 10_000);
2296
+
2297
+ it("survives a poisoned message without throwing into the caller or losing the delta", async () => {
2298
+ // CRITICAL contract: an advisor render failure (throwing getter,
2299
+ // formatter bug) must neither propagate into the primary agent's
2300
+ // turn-end callback nor park it on the catch-up gate — and the
2301
+ // unrendered delta must survive for the next turn.
2302
+ const promptInputs: string[] = [];
2303
+ const agent: AdvisorAgent = {
2304
+ prompt: async input => {
2305
+ promptInputs.push(input);
2306
+ },
2307
+ abort: () => {},
2308
+ reset: () => {},
2309
+ state: { messages: [] },
2310
+ };
2311
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
2312
+ const host: AdvisorRuntimeHost = {
2313
+ snapshotMessages: () => messages,
2314
+ enqueueAdvice: () => {},
2315
+ };
2316
+ const runtime = new AdvisorRuntime(agent, host, 0);
2317
+ runtime.onTurnEnd(messages);
2318
+ await settleUntil(() => promptInputs.length >= 1);
2319
+ expect(promptInputs).toHaveLength(1);
2320
+
2321
+ // Poison: reading `content` throws — during the size probe or render.
2322
+ const poisoned = {
2323
+ role: "user",
2324
+ get content(): string {
2325
+ throw new Error("poisoned message");
2326
+ },
2327
+ timestamp: 2,
2328
+ } as AgentMessage;
2329
+ messages.push(poisoned);
2330
+ expect(() => runtime.onTurnEnd(messages)).not.toThrow();
2331
+ // A parked primary must not wait out the catch-up budget.
2332
+ const started = performance.now();
2333
+ await runtime.waitForCatchup(60_000, 1);
2334
+ expect(performance.now() - started).toBeLessThan(2_000);
2335
+ await settleUntil(() => runtime.backlog === 0);
2336
+
2337
+ // Replace the poison with a healthy message: the cursor was restored,
2338
+ // so the next turn re-renders from the failed position.
2339
+ messages[1] = { role: "user", content: "bbb-recovered", timestamp: 2 } as AgentMessage;
2340
+ messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
2341
+ runtime.onTurnEnd(messages);
2342
+ await settleUntil(() => promptInputs.length >= 2);
2343
+ expect(promptInputs).toHaveLength(2);
2344
+ expect(promptInputs[1]).toContain("bbb-recovered");
2345
+ expect(promptInputs[1]).toContain("ccc");
2346
+ runtime.dispose();
2347
+ }, 10_000);
2348
+
2349
+ // The live incident shape: ONE agent + ONE advisor froze the whole
2350
+ // process when a post-reset replay rendered a multi-MB transcript. These
2351
+ // tests pin the correctness contracts for large deltas: complete
2352
+ // delivery, tool call/result pairing, ordering across interleaved
2353
+ // turns, and full replay after a mid-render reset.
2354
+ describe("large-transcript responsiveness", () => {
2355
+ const bigMessage = (i: number, chars = 5_000): AgentMessage => {
2356
+ const text = `msg-${i} ${"x".repeat(chars)}`;
2357
+ return (
2358
+ i % 2
2359
+ ? { role: "assistant", content: [{ type: "text", text }], timestamp: i }
2360
+ : { role: "user", content: text, timestamp: i }
2361
+ ) as AgentMessage;
2362
+ };
2363
+
2364
+ const waitForPrompts = async (prompts: string[], count: number, timeoutMs = 10_000): Promise<void> => {
2365
+ const deadline = Date.now() + timeoutMs;
2366
+ while (prompts.length < count && Date.now() < deadline) await Bun.sleep(5);
2367
+ };
2368
+
2369
+ it("delivers a multi-MB transcript replay completely", async () => {
2370
+ const promptInputs: string[] = [];
2371
+ const agent: AdvisorAgent = {
2372
+ prompt: async input => {
2373
+ promptInputs.push(input);
2374
+ },
2375
+ abort: () => {},
2376
+ reset: () => {},
2377
+ state: { messages: [] },
2378
+ };
2379
+ // ~2000 × 5KB ≈ 10MB replay — the post-reset/first-enable shape.
2380
+ const messages = Array.from({ length: 2000 }, (_, i) => bigMessage(i));
2381
+ const host: AdvisorRuntimeHost = {
2382
+ snapshotMessages: () => messages,
2383
+ enqueueAdvice: () => {},
2384
+ };
2385
+ const runtime = new AdvisorRuntime(agent, host, 0);
2386
+ runtime.onTurnEnd(messages);
2387
+ await waitForPrompts(promptInputs, 1);
2388
+ expect(promptInputs).toHaveLength(1);
2389
+ // Nothing dropped: first and last transcript messages both rendered.
2390
+ expect(promptInputs[0]).toContain("msg-0 ");
2391
+ expect(promptInputs[0]).toContain("msg-1999 ");
2392
+ runtime.dispose();
2393
+ }, 20_000);
2394
+
2395
+ it("pairs a toolCall with its non-adjacent toolResult inside one update", async () => {
2396
+ const promptInputs: string[] = [];
2397
+ const agent: AdvisorAgent = {
2398
+ prompt: async input => {
2399
+ promptInputs.push(input);
2400
+ },
2401
+ abort: () => {},
2402
+ reset: () => {},
2403
+ state: { messages: [] },
2404
+ };
2405
+ // The toolCall sits at index 99 and its result arrives 49 messages
2406
+ // later (index 148), far past any adjacency window: only the
2407
+ // whole-delta result index can pair them.
2408
+ const messages: AgentMessage[] = Array.from({ length: 150 }, (_, i) => bigMessage(i, 64));
2409
+ messages[99] = {
2410
+ role: "assistant",
2411
+ content: [{ type: "toolCall", id: "call-split", name: "read", arguments: { path: "x" } }],
2412
+ timestamp: 99,
2413
+ } as unknown as AgentMessage;
2414
+ messages[100] = {
2415
+ role: "custom",
2416
+ customType: "hook",
2417
+ content: "interleaved",
2418
+ timestamp: 100,
2419
+ } as AgentMessage;
2420
+ messages[148] = {
2421
+ role: "toolResult",
2422
+ toolCallId: "call-split",
2423
+ content: [{ type: "text", text: "result-body" }],
2424
+ timestamp: 148,
2425
+ } as AgentMessage;
2426
+ const host: AdvisorRuntimeHost = {
2427
+ snapshotMessages: () => messages,
2428
+ enqueueAdvice: () => {},
2429
+ };
2430
+ const runtime = new AdvisorRuntime(agent, host, 0);
2431
+ runtime.onTurnEnd(messages);
2432
+ await waitForPrompts(promptInputs, 1);
2433
+ expect(promptInputs).toHaveLength(1);
2434
+ expect(promptInputs[0]).toContain("read(");
2435
+ // The call+result pair rendered as completed, never as a spurious
2436
+ // in-flight call.
2437
+ expect(promptInputs[0]).toContain("⇒ ok");
2438
+ expect(promptInputs[0]).not.toContain("⇒ pending");
2439
+ runtime.dispose();
2440
+ }, 20_000);
2441
+
2442
+ it("delivers a single turn carrying a multi-MB payload", async () => {
2443
+ const promptInputs: string[] = [];
2444
+ const agent: AdvisorAgent = {
2445
+ prompt: async input => {
2446
+ promptInputs.push(input);
2447
+ },
2448
+ abort: () => {},
2449
+ reset: () => {},
2450
+ state: { messages: [] },
2451
+ };
2452
+ const messages: AgentMessage[] = [{ role: "user", content: "before", timestamp: 1 } as AgentMessage];
2453
+ const host: AdvisorRuntimeHost = {
2454
+ snapshotMessages: () => messages,
2455
+ enqueueAdvice: () => {},
2456
+ };
2457
+ const runtime = new AdvisorRuntime(agent, host, 0);
2458
+ runtime.onTurnEnd(messages);
2459
+ await waitForPrompts(promptInputs, 1);
2460
+ expect(promptInputs).toHaveLength(1);
2461
+
2462
+ // One turn, one message, multi-MB body (an edit-diff-sized payload)
2463
+ // must deliver completely.
2464
+ messages.push({
2465
+ role: "assistant",
2466
+ content: [{ type: "text", text: `huge ${"y".repeat(3_000_000)}` }],
2467
+ timestamp: 2,
2468
+ } as AgentMessage);
2469
+ runtime.onTurnEnd(messages);
2470
+ await waitForPrompts(promptInputs, 2);
2471
+ expect(promptInputs).toHaveLength(2);
2472
+ expect(promptInputs[1]).toContain("huge ");
2473
+ runtime.dispose();
2474
+ }, 20_000);
2475
+
2476
+ it("replays the full transcript after a reset lands between renders", async () => {
2477
+ const promptInputs: string[] = [];
2478
+ const agent: AdvisorAgent = {
2479
+ prompt: async input => {
2480
+ promptInputs.push(input);
2481
+ },
2482
+ abort: () => {},
2483
+ reset: () => {},
2484
+ state: { messages: [] },
2485
+ };
2486
+ const messages = Array.from({ length: 400 }, (_, i) => bigMessage(i));
2487
+ const host: AdvisorRuntimeHost = {
2488
+ snapshotMessages: () => messages,
2489
+ enqueueAdvice: () => {},
2490
+ };
2491
+ const runtime = new AdvisorRuntime(agent, host, 0);
2492
+ runtime.onTurnEnd(messages);
2493
+ runtime.reset();
2494
+ runtime.onTurnEnd(messages);
2495
+ await waitForPrompts(promptInputs, 1);
2496
+ // The aborted pre-reset render must not have advanced the cursor:
2497
+ // the post-reset replay carries the whole transcript.
2498
+ const replay = promptInputs.find(input => input.includes("msg-0 ") && input.includes("msg-399 "));
2499
+ expect(replay).toBeDefined();
2500
+ runtime.dispose();
2501
+ }, 20_000);
2502
+
2503
+ it("delivers interleaved turns in order without loss", async () => {
2504
+ const promptInputs: string[] = [];
2505
+ const agent: AdvisorAgent = {
2506
+ prompt: async input => {
2507
+ promptInputs.push(input);
2508
+ },
2509
+ abort: () => {},
2510
+ reset: () => {},
2511
+ state: { messages: [] },
2512
+ };
2513
+ const messages = Array.from({ length: 300 }, (_, i) => bigMessage(i));
2514
+ const host: AdvisorRuntimeHost = {
2515
+ snapshotMessages: () => messages,
2516
+ enqueueAdvice: () => {},
2517
+ };
2518
+ const runtime = new AdvisorRuntime(agent, host, 0);
2519
+ runtime.onTurnEnd(messages);
2520
+ // Second turn arrives immediately behind the first.
2521
+ messages.push({ role: "user", content: "late-arrival tail", timestamp: 300 } as AgentMessage);
2522
+ runtime.onTurnEnd(messages);
2523
+ const deadline = Date.now() + 10_000;
2524
+ while (Date.now() < deadline && !promptInputs.join("\n").includes("late-arrival tail")) await Bun.sleep(5);
2525
+ const combined = promptInputs.join("\n");
2526
+ // Every message exactly once, ordering preserved.
2527
+ expect(combined).toContain("msg-0 ");
2528
+ expect(combined).toContain("msg-299 ");
2529
+ expect(combined.indexOf("msg-299 ")).toBeGreaterThan(combined.indexOf("msg-0 "));
2530
+ expect(combined.indexOf("late-arrival tail")).toBeGreaterThan(combined.indexOf("msg-299 "));
2531
+ expect(combined.match(/msg-150 /g)).toHaveLength(1);
2532
+ expect(combined.match(/late-arrival tail/g)).toHaveLength(1);
2533
+ runtime.dispose();
2534
+ }, 20_000);
2535
+ });
2536
+
2537
+ it("treats a clean prompt resolution with state.error as a failed turn (real Agent contract)", async () => {
2538
+ // `Agent.#runLoop` catches provider/stream failures internally — it resolves
2539
+ // `prompt()` cleanly and stores the message on `state.error` (e.g. the
2540
+ // OpenRouter ZDR `404 No endpoints available` case from #3635). The runtime
2541
+ // must surface that as a failed turn even though the awaited promise did
2542
+ // not reject.
2543
+ const promptInputs: string[] = [];
2544
+ const failures: unknown[] = [];
2545
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
2546
+ let shouldFail = true;
2547
+ const agent: AdvisorAgent = {
2548
+ prompt: async input => {
2549
+ promptInputs.push(input);
2550
+ state.error = shouldFail
2551
+ ? "404 No endpoints available matching your guardrail restrictions and data policy."
2552
+ : undefined;
2553
+ },
2554
+ abort: () => {},
2555
+ reset: () => {
2556
+ state.error = undefined;
2557
+ },
2558
+ state,
2559
+ };
2560
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
2561
+ const host: AdvisorRuntimeHost = {
2562
+ snapshotMessages: () => messages,
2563
+ enqueueAdvice: () => {},
2564
+ notifyFailure: error => failures.push(error),
2565
+ };
2566
+ const runtime = new AdvisorRuntime(agent, host, 0);
2567
+
2568
+ runtime.onTurnEnd(messages);
2569
+ await Bun.sleep(0);
2570
+ await Bun.sleep(0);
2571
+ await Bun.sleep(0);
2572
+
2573
+ expect(promptInputs).toHaveLength(3);
2574
+ expect(failures).toHaveLength(1);
2575
+ const failure = failures[0];
2576
+ if (!(failure instanceof Error)) throw new Error("expected advisor failure error");
2577
+ expect(failure.message).toContain("No endpoints available");
2578
+ expect(runtime.backlog).toBe(0);
2579
+
2580
+ shouldFail = false;
2581
+ messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
2582
+ runtime.onTurnEnd(messages);
2583
+ await Bun.sleep(0);
2584
+ expect(failures).toHaveLength(1);
2585
+
2586
+ shouldFail = true;
2587
+ messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
2588
+ runtime.onTurnEnd(messages);
2589
+ await Bun.sleep(0);
2590
+ await Bun.sleep(0);
2591
+ await Bun.sleep(0);
2592
+
2593
+ expect(failures).toHaveLength(2);
1857
2594
  });
1858
2595
 
1859
2596
  it("accepts a zero-usage empty stop as a successful silent review", async () => {
@@ -2078,7 +2815,7 @@ describe("advisor", () => {
2078
2815
  const runtime = new AdvisorRuntime(agent, host, 1);
2079
2816
 
2080
2817
  runtime.onTurnEnd(messages);
2081
- await runtime.waitForCatchup(1000, 1);
2818
+ await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0);
2082
2819
 
2083
2820
  expect(promptInputs).toHaveLength(2);
2084
2821
  expect(turnErrors).toHaveLength(1);
@@ -2125,7 +2862,7 @@ describe("advisor", () => {
2125
2862
  const runtime = new AdvisorRuntime(agent, host, 1);
2126
2863
 
2127
2864
  runtime.onTurnEnd(messages);
2128
- await runtime.waitForCatchup(1000, 1);
2865
+ await settleUntil(() => failures.length >= 1 && runtime.backlog === 0);
2129
2866
 
2130
2867
  expect(promptInputs).toHaveLength(3);
2131
2868
  expect(turnErrors.map(error => (error instanceof Error ? error.message : String(error)))).toEqual([
@@ -2181,7 +2918,7 @@ describe("advisor", () => {
2181
2918
  const runtime = new AdvisorRuntime(agent, host, 1);
2182
2919
 
2183
2920
  runtime.onTurnEnd(messages);
2184
- await runtime.waitForCatchup(1000, 1);
2921
+ await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0);
2185
2922
 
2186
2923
  expect(promptInputs).toHaveLength(2);
2187
2924
  expect(turnErrors).toHaveLength(1);
@@ -2192,6 +2929,74 @@ describe("advisor", () => {
2192
2929
  expect(runtime.backlog).toBe(0);
2193
2930
  });
2194
2931
 
2932
+ it("drops a terminal non-retriable assistant failure without retrying", async () => {
2933
+ const errorMessage = "Codex error event: Request blocked. (code=invalid_prompt)";
2934
+ const promptInputs: string[] = [];
2935
+ const rollbackCalls: number[] = [];
2936
+ const turnErrors: unknown[] = [];
2937
+ const failures: unknown[] = [];
2938
+ const state: { messages: AgentMessage[]; error?: string } = { messages: [] };
2939
+ const agent: AdvisorAgent = {
2940
+ prompt: async input => {
2941
+ promptInputs.push(input);
2942
+ state.messages.push({ role: "user", content: input, timestamp: 1 } as AgentMessage);
2943
+ const failure: AssistantMessage = {
2944
+ role: "assistant",
2945
+ content: [],
2946
+ api: "openai-codex-responses",
2947
+ provider: "openai-codex",
2948
+ model: "gpt-5.6-sol",
2949
+ usage: {
2950
+ input: 1,
2951
+ output: 0,
2952
+ cacheRead: 0,
2953
+ cacheWrite: 0,
2954
+ totalTokens: 1,
2955
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2956
+ },
2957
+ stopReason: "error",
2958
+ errorMessage,
2959
+ errorId: 0,
2960
+ timestamp: 2,
2961
+ };
2962
+ state.messages.push(failure);
2963
+ state.error = errorMessage;
2964
+ },
2965
+ abort: () => {},
2966
+ reset: () => {
2967
+ state.messages.length = 0;
2968
+ state.error = undefined;
2969
+ },
2970
+ rollbackTo: count => {
2971
+ rollbackCalls.push(count);
2972
+ state.messages.length = Math.min(count, state.messages.length);
2973
+ state.error = undefined;
2974
+ },
2975
+ state,
2976
+ };
2977
+ const messages: AgentMessage[] = [{ role: "user", content: "aaa", timestamp: 1 } as AgentMessage];
2978
+ const host: AdvisorRuntimeHost = {
2979
+ snapshotMessages: () => messages,
2980
+ enqueueAdvice: () => {},
2981
+ onTurnError: error => {
2982
+ turnErrors.push(error);
2983
+ },
2984
+ notifyFailure: error => {
2985
+ failures.push(error);
2986
+ },
2987
+ };
2988
+ const runtime = new AdvisorRuntime(agent, host, 1);
2989
+
2990
+ runtime.onTurnEnd(messages);
2991
+ await settleUntil(() => failures.length >= 1 && runtime.backlog === 0);
2992
+
2993
+ expect(promptInputs).toHaveLength(1);
2994
+ expect(rollbackCalls).toEqual([0]);
2995
+ expect(turnErrors).toHaveLength(1);
2996
+ expect(failures).toHaveLength(1);
2997
+ expect(runtime.backlog).toBe(0);
2998
+ });
2999
+
2195
3000
  it("rolls advisor state back after each failed prompt so retries don't replay duplicate turns", async () => {
2196
3001
  // The real `Agent` appends the user batch + a synthetic `stopReason: "error"`
2197
3002
  // assistant turn before `state.error` is read. Without rollback, the runtime's
@@ -2212,6 +3017,7 @@ describe("advisor", () => {
2212
3017
  content: [{ type: "text", text: "" }],
2213
3018
  stopReason: "error",
2214
3019
  errorMessage: "404 No endpoints available",
3020
+ errorId: AIError.create(AIError.Flag.Transient),
2215
3021
  timestamp: Date.now(),
2216
3022
  } as unknown as AgentMessage);
2217
3023
  state.error = "404 No endpoints available";
@@ -2320,7 +3126,7 @@ describe("advisor", () => {
2320
3126
  const runtime = new AdvisorRuntime(agent, host, 0);
2321
3127
 
2322
3128
  runtime.onTurnEnd(messages);
2323
- await runtime.waitForCatchup(1000, 1);
3129
+ await settleUntil(() => promptInputs.length >= 1 && runtime.backlog === 0);
2324
3130
 
2325
3131
  expect(promptInputs).toHaveLength(1);
2326
3132
  expect(resetCalls).toBe(1);
@@ -2329,7 +3135,7 @@ describe("advisor", () => {
2329
3135
 
2330
3136
  messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
2331
3137
  runtime.onTurnEnd(messages);
2332
- await runtime.waitForCatchup(1000, 1);
3138
+ await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0);
2333
3139
 
2334
3140
  expect(promptInputs).toHaveLength(2);
2335
3141
  expect(lengthsBeforePrompt).toEqual([0, 0]);
@@ -2370,7 +3176,7 @@ describe("advisor", () => {
2370
3176
  messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
2371
3177
  runtime.onTurnEnd(messages);
2372
3178
  rejectFirstPrompt(new AdvisorOutputQuarantinedError("quarantined"));
2373
- await runtime.waitForCatchup(1000, 1);
3179
+ await settleUntil(() => promptInputs.length >= 2 && runtime.backlog === 0);
2374
3180
 
2375
3181
  expect(promptInputs).toHaveLength(2);
2376
3182
  expect(promptInputs[1]).toContain("aaa");
@@ -2435,6 +3241,445 @@ describe("advisor", () => {
2435
3241
  });
2436
3242
  });
2437
3243
 
3244
+ describe("AdvisorRuntime quota classification", () => {
3245
+ it("pauses on quota/rate-limit errors and notifies the host without retrying", async () => {
3246
+ const promptInputs: string[] = [];
3247
+ let quotaNotified = false;
3248
+ let failureNotified = false;
3249
+ const agent: AdvisorAgent = {
3250
+ prompt: async input => {
3251
+ promptInputs.push(input);
3252
+ throw new Error("resource_exhausted");
3253
+ },
3254
+ abort: () => {},
3255
+ reset: () => {},
3256
+ state: { messages: [] },
3257
+ };
3258
+ const host: AdvisorRuntimeHost = {
3259
+ snapshotMessages: () => [],
3260
+ enqueueAdvice: () => {},
3261
+ notifyFailure: () => {
3262
+ failureNotified = true;
3263
+ },
3264
+ notifyQuotaExhausted: () => {
3265
+ quotaNotified = true;
3266
+ },
3267
+ };
3268
+ const runtime = new AdvisorRuntime(agent, host, 0);
3269
+
3270
+ const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
3271
+ runtime.onTurnEnd(messages);
3272
+ await Bun.sleep(0);
3273
+ await Bun.sleep(0);
3274
+
3275
+ // Quota path: single prompt attempt, no retries, no generic failure.
3276
+ expect(promptInputs).toHaveLength(1);
3277
+ expect(runtime.quotaExhausted).toBe(true);
3278
+ expect(quotaNotified).toBe(true);
3279
+ expect(failureNotified).toBe(false);
3280
+
3281
+ // Subsequent turns are skipped while quota-exhausted.
3282
+ messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage);
3283
+ runtime.onTurnEnd(messages);
3284
+ await Bun.sleep(0);
3285
+ expect(promptInputs).toHaveLength(1);
3286
+ });
3287
+
3288
+ it("treats 'overloaded' as a transient server error, not quota exhaustion", async () => {
3289
+ const promptInputs: string[] = [];
3290
+ const failures: unknown[] = [];
3291
+ const agent: AdvisorAgent = {
3292
+ prompt: async input => {
3293
+ promptInputs.push(input);
3294
+ throw new Error("overloaded: server is at capacity");
3295
+ },
3296
+ abort: () => {},
3297
+ reset: () => {},
3298
+ state: { messages: [] },
3299
+ };
3300
+ const host: AdvisorRuntimeHost = {
3301
+ snapshotMessages: () => [],
3302
+ enqueueAdvice: () => {},
3303
+ notifyFailure: error => failures.push(error),
3304
+ };
3305
+ const runtime = new AdvisorRuntime(agent, host, 0);
3306
+
3307
+ const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
3308
+ runtime.onTurnEnd(messages);
3309
+ await Bun.sleep(0);
3310
+ await Bun.sleep(0);
3311
+ await Bun.sleep(0);
3312
+
3313
+ // Overloaded follows the 3-retry → notifyFailure path, not the quota path.
3314
+ expect(promptInputs).toHaveLength(3);
3315
+ expect(runtime.quotaExhausted).toBe(false);
3316
+ expect(failures).toHaveLength(1);
3317
+ });
3318
+ it("retains the failed batch in the pending queue on quota error", async () => {
3319
+ const promptInputs: string[] = [];
3320
+ let shouldFail = true;
3321
+ const agent: AdvisorAgent = {
3322
+ prompt: async input => {
3323
+ promptInputs.push(input);
3324
+ if (shouldFail) throw new Error("insufficient_quota: rate limit exceeded");
3325
+ },
3326
+ abort: () => {},
3327
+ reset: () => {},
3328
+ state: { messages: [] },
3329
+ };
3330
+ const host: AdvisorRuntimeHost = {
3331
+ snapshotMessages: () => [],
3332
+ enqueueAdvice: () => {},
3333
+ notifyQuotaExhausted: () => {},
3334
+ };
3335
+ const runtime = new AdvisorRuntime(agent, host, 0);
3336
+ const messages: AgentMessage[] = [{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage];
3337
+ runtime.onTurnEnd(messages);
3338
+ await Bun.sleep(0);
3339
+ await Bun.sleep(0);
3340
+
3341
+ // The batch must remain in the queue (backlog > 0) so it's replayed
3342
+ // once the quota window resets, instead of being silently dropped.
3343
+ expect(runtime.quotaExhausted).toBe(true);
3344
+ expect(runtime.backlog).toBeGreaterThan(0);
3345
+ expect(promptInputs).toHaveLength(1);
3346
+ expect(promptInputs[0]).toContain("quota-turn");
3347
+
3348
+ // After reset() clears the quota pause, the next onTurnEnd drains the
3349
+ // retained batch — proving it was never lost.
3350
+ shouldFail = false;
3351
+ runtime.reset();
3352
+ runtime.onTurnEnd(messages);
3353
+ await Bun.sleep(0);
3354
+ await Bun.sleep(0);
3355
+ expect(promptInputs.at(-1)).toContain("quota-turn");
3356
+ });
3357
+
3358
+ it("resolves waitForCatchup immediately when quota is exhausted", async () => {
3359
+ const agent: AdvisorAgent = {
3360
+ prompt: async () => {
3361
+ throw new Error("insufficient_quota");
3362
+ },
3363
+ abort: () => {},
3364
+ reset: () => {},
3365
+ state: { messages: [] },
3366
+ };
3367
+ const host: AdvisorRuntimeHost = {
3368
+ snapshotMessages: () => [],
3369
+ enqueueAdvice: () => {},
3370
+ notifyQuotaExhausted: () => {},
3371
+ };
3372
+ const runtime = new AdvisorRuntime(agent, host, 0);
3373
+ const messages: AgentMessage[] = [{ role: "user", content: "turn", timestamp: 1 } as AgentMessage];
3374
+ runtime.onTurnEnd(messages);
3375
+ await Bun.sleep(0);
3376
+ await Bun.sleep(0);
3377
+
3378
+ expect(runtime.quotaExhausted).toBe(true);
3379
+ expect(runtime.backlog).toBeGreaterThan(0);
3380
+
3381
+ // waitForCatchup must resolve instantly — a quota-paused advisor can't
3382
+ // make progress, so blocking the primary agent for 30s is wrong.
3383
+ const start = Date.now();
3384
+ await runtime.waitForCatchup(30_000, 1);
3385
+ expect(Date.now() - start).toBeLessThan(1000);
3386
+ });
3387
+ it("retries once when onTurnError signals a switched sibling credential", async () => {
3388
+ const promptInputs: string[] = [];
3389
+ let firstCall = true;
3390
+ const agent: AdvisorAgent = {
3391
+ prompt: async input => {
3392
+ promptInputs.push(input);
3393
+ if (firstCall) {
3394
+ firstCall = false;
3395
+ throw new Error("insufficient_quota: you have exceeded your rate limit");
3396
+ }
3397
+ },
3398
+ abort: () => {},
3399
+ reset: () => {},
3400
+ state: { messages: [] },
3401
+ };
3402
+ let quotaNotified = false;
3403
+ const host: AdvisorRuntimeHost = {
3404
+ snapshotMessages: () => [],
3405
+ enqueueAdvice: () => {},
3406
+ onTurnError: async () => true,
3407
+ notifyQuotaExhausted: () => {
3408
+ quotaNotified = true;
3409
+ },
3410
+ };
3411
+ const runtime = new AdvisorRuntime(agent, host, 0);
3412
+
3413
+ const messages: AgentMessage[] = [{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage];
3414
+ runtime.onTurnEnd(messages);
3415
+ await Bun.sleep(0);
3416
+ await Bun.sleep(0);
3417
+ await Bun.sleep(0);
3418
+
3419
+ // Sibling credential switched: retry succeeds, no quota pause.
3420
+ expect(promptInputs).toHaveLength(2);
3421
+ expect(runtime.quotaExhausted).toBe(false);
3422
+ expect(quotaNotified).toBe(false);
3423
+ expect(runtime.backlog).toBe(0);
3424
+ });
3425
+
3426
+ it("requeues when a switched retry produces no assistant response", async () => {
3427
+ const promptInputs: string[] = [];
3428
+ const state = { messages: [] as AgentMessage[] };
3429
+ let callCount = 0;
3430
+ const agent: AdvisorAgent = {
3431
+ prompt: async input => {
3432
+ promptInputs.push(input);
3433
+ callCount++;
3434
+ if (callCount === 1) throw new Error("insufficient_quota");
3435
+ if (callCount === 2) {
3436
+ state.messages.push({ role: "user", content: input, timestamp: Date.now() } as AgentMessage);
3437
+ }
3438
+ },
3439
+ abort: () => {},
3440
+ reset: () => {},
3441
+ rollbackTo: count => state.messages.splice(count),
3442
+ state,
3443
+ };
3444
+ const hookErrors: unknown[] = [];
3445
+ const host: AdvisorRuntimeHost = {
3446
+ snapshotMessages: () => [],
3447
+ enqueueAdvice: () => {},
3448
+ onTurnError: async error => {
3449
+ hookErrors.push(error);
3450
+ return hookErrors.length === 1;
3451
+ },
3452
+ };
3453
+ const runtime = new AdvisorRuntime(agent, host, 0);
3454
+
3455
+ runtime.onTurnEnd([{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage]);
3456
+ await settleUntil(() => promptInputs.length >= 3 && runtime.backlog === 0);
3457
+
3458
+ expect(promptInputs).toHaveLength(3);
3459
+ expect(hookErrors).toHaveLength(2);
3460
+ expect(runtime.backlog).toBe(0);
3461
+ });
3462
+
3463
+ it("falls through to quota pause when onTurnError returns false (no sibling)", async () => {
3464
+ const promptInputs: string[] = [];
3465
+ const agent: AdvisorAgent = {
3466
+ prompt: async input => {
3467
+ promptInputs.push(input);
3468
+ throw new Error("insufficient_quota: you have exceeded your rate limit");
3469
+ },
3470
+ abort: () => {},
3471
+ reset: () => {},
3472
+ state: { messages: [] },
3473
+ };
3474
+ let quotaNotified = false;
3475
+ const host: AdvisorRuntimeHost = {
3476
+ snapshotMessages: () => [],
3477
+ enqueueAdvice: () => {},
3478
+ onTurnError: async () => false,
3479
+ notifyQuotaExhausted: () => {
3480
+ quotaNotified = true;
3481
+ },
3482
+ };
3483
+ const runtime = new AdvisorRuntime(agent, host, 0);
3484
+
3485
+ const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
3486
+ runtime.onTurnEnd(messages);
3487
+ await Bun.sleep(0);
3488
+ await Bun.sleep(0);
3489
+
3490
+ // No sibling: single prompt, then quota pause (no retry).
3491
+ expect(promptInputs).toHaveLength(1);
3492
+ expect(runtime.quotaExhausted).toBe(true);
3493
+ expect(quotaNotified).toBe(true);
3494
+ });
3495
+ it("drops stale quota handling when reset happens during onTurnError", async () => {
3496
+ const promptInputs: string[] = [];
3497
+ const agent: AdvisorAgent = {
3498
+ prompt: async input => {
3499
+ promptInputs.push(input);
3500
+ if (input.includes("stale-turn")) {
3501
+ throw new Error("insufficient_quota: you have exceeded your rate limit");
3502
+ }
3503
+ },
3504
+ abort: () => {},
3505
+ reset: () => {},
3506
+ state: { messages: [] },
3507
+ };
3508
+ let quotaNotified = 0;
3509
+ let hookInvocations = 0;
3510
+ const { promise: hookEntered, resolve: allowHook } = Promise.withResolvers<void>();
3511
+ const { promise: hookProceed, resolve: proceedHook } = Promise.withResolvers<void>();
3512
+ const host: AdvisorRuntimeHost = {
3513
+ snapshotMessages: () => [],
3514
+ enqueueAdvice: () => {},
3515
+ onTurnError: async () => {
3516
+ hookInvocations++;
3517
+ allowHook();
3518
+ await hookProceed;
3519
+ return false;
3520
+ },
3521
+ notifyQuotaExhausted: () => {
3522
+ quotaNotified++;
3523
+ },
3524
+ };
3525
+ const runtime = new AdvisorRuntime(agent, host, 0);
3526
+
3527
+ runtime.onTurnEnd([{ role: "user", content: "stale-turn", timestamp: 1 } as AgentMessage]);
3528
+ await hookEntered;
3529
+ runtime.reset();
3530
+ runtime.onTurnEnd([{ role: "user", content: "fresh-turn", timestamp: 2 } as AgentMessage]);
3531
+ proceedHook();
3532
+ await runtime.waitForCatchup(1000, 1);
3533
+
3534
+ expect(hookInvocations).toBe(1);
3535
+ expect(promptInputs).toHaveLength(2);
3536
+ expect(promptInputs[0]).toContain("stale-turn");
3537
+ expect(promptInputs[1]).toContain("fresh-turn");
3538
+ expect(runtime.quotaExhausted).toBe(false);
3539
+ expect(runtime.backlog).toBe(0);
3540
+ expect(quotaNotified).toBe(0);
3541
+ });
3542
+ it("uses generic failure path when switched retry hits a non-quota error", async () => {
3543
+ const promptInputs: string[] = [];
3544
+ let callCount = 0;
3545
+ const agent: AdvisorAgent = {
3546
+ prompt: async input => {
3547
+ promptInputs.push(input);
3548
+ callCount++;
3549
+ if (callCount === 1) {
3550
+ throw new Error("insufficient_quota: you have exceeded your rate limit");
3551
+ }
3552
+ if (callCount === 2) {
3553
+ throw new Error("ECONNRESET: socket hang up");
3554
+ }
3555
+ // callCount >= 3: success
3556
+ },
3557
+ abort: () => {},
3558
+ reset: () => {},
3559
+ state: { messages: [] },
3560
+ };
3561
+ const hookErrors: unknown[] = [];
3562
+ let quotaNotified = false;
3563
+ const host: AdvisorRuntimeHost = {
3564
+ snapshotMessages: () => [],
3565
+ enqueueAdvice: () => {},
3566
+ onTurnError: async error => {
3567
+ hookErrors.push(error);
3568
+ return hookErrors.length === 1 ? true : undefined;
3569
+ },
3570
+ notifyQuotaExhausted: () => {
3571
+ quotaNotified = true;
3572
+ },
3573
+ };
3574
+ const runtime = new AdvisorRuntime(agent, host, 0);
3575
+
3576
+ const messages: AgentMessage[] = [{ role: "user", content: "mixed-turn", timestamp: 1 } as AgentMessage];
3577
+ runtime.onTurnEnd(messages);
3578
+ await Bun.sleep(0);
3579
+ await Bun.sleep(0);
3580
+ await Bun.sleep(0);
3581
+ await Bun.sleep(0);
3582
+ await Bun.sleep(0);
3583
+
3584
+ // Sibling switched (call 1 quota), retry failed with non-quota
3585
+ // (call 2), then succeeded (call 3). No quota pause, backlog cleared.
3586
+ expect(promptInputs).toHaveLength(3);
3587
+ expect(runtime.quotaExhausted).toBe(false);
3588
+ expect(quotaNotified).toBe(false);
3589
+ expect(runtime.backlog).toBe(0);
3590
+ // Hook sees both errors: the original quota (switched) and the
3591
+ // retry's non-quota (generic path, no switch).
3592
+ expect(hookErrors).toHaveLength(2);
3593
+ });
3594
+
3595
+ it("marks sibling and pauses when switched retry hits a second quota error", async () => {
3596
+ const promptInputs: string[] = [];
3597
+ let firstCall = true;
3598
+ const agent: AdvisorAgent = {
3599
+ prompt: async input => {
3600
+ promptInputs.push(input);
3601
+ if (firstCall) {
3602
+ firstCall = false;
3603
+ throw new Error("insufficient_quota: you have exceeded your rate limit");
3604
+ }
3605
+ throw new Error("429 Too Many Requests: quota exceeded");
3606
+ },
3607
+ abort: () => {},
3608
+ reset: () => {},
3609
+ state: { messages: [] },
3610
+ };
3611
+ const hookErrors: unknown[] = [];
3612
+ let quotaNotified = false;
3613
+ const host: AdvisorRuntimeHost = {
3614
+ snapshotMessages: () => [],
3615
+ enqueueAdvice: () => {},
3616
+ onTurnError: async error => {
3617
+ hookErrors.push(error);
3618
+ return hookErrors.length === 1 ? true : undefined;
3619
+ },
3620
+ notifyQuotaExhausted: () => {
3621
+ quotaNotified = true;
3622
+ },
3623
+ };
3624
+ const runtime = new AdvisorRuntime(agent, host, 0);
3625
+
3626
+ const messages: AgentMessage[] = [{ role: "user", content: "double-quota", timestamp: 1 } as AgentMessage];
3627
+ runtime.onTurnEnd(messages);
3628
+ await Bun.sleep(0);
3629
+ await Bun.sleep(0);
3630
+ await Bun.sleep(0);
3631
+
3632
+ // Both credentials exhausted: retry prompted twice, then entered quota pause.
3633
+ expect(promptInputs).toHaveLength(2);
3634
+ expect(runtime.quotaExhausted).toBe(true);
3635
+ expect(quotaNotified).toBe(true);
3636
+ // Hook marks both the original credential (switched=true) and the
3637
+ // newly exhausted sibling on the second quota error.
3638
+ expect(hookErrors).toHaveLength(2);
3639
+ });
3640
+
3641
+ it("keeps rotating while another credential is immediately available", async () => {
3642
+ const promptInputs: string[] = [];
3643
+ const agent: AdvisorAgent = {
3644
+ prompt: async input => {
3645
+ promptInputs.push(input);
3646
+ if (promptInputs.length <= 2) {
3647
+ throw new Error("429 Too Many Requests: quota exceeded");
3648
+ }
3649
+ },
3650
+ abort: () => {},
3651
+ reset: () => {},
3652
+ state: { messages: [] },
3653
+ };
3654
+ const hookErrors: unknown[] = [];
3655
+ let quotaNotified = false;
3656
+ const host: AdvisorRuntimeHost = {
3657
+ snapshotMessages: () => [],
3658
+ enqueueAdvice: () => {},
3659
+ onTurnError: async error => {
3660
+ hookErrors.push(error);
3661
+ return true;
3662
+ },
3663
+ notifyQuotaExhausted: () => {
3664
+ quotaNotified = true;
3665
+ },
3666
+ };
3667
+ const runtime = new AdvisorRuntime(agent, host, 0);
3668
+
3669
+ const messages: AgentMessage[] = [
3670
+ { role: "user", content: "triple-credential", timestamp: 1 } as AgentMessage,
3671
+ ];
3672
+ runtime.onTurnEnd(messages);
3673
+ await settleUntil(() => promptInputs.length >= 3 && runtime.backlog === 0);
3674
+
3675
+ expect(promptInputs).toHaveLength(3);
3676
+ expect(hookErrors).toHaveLength(2);
3677
+ expect(runtime.quotaExhausted).toBe(false);
3678
+ expect(quotaNotified).toBe(false);
3679
+ expect(runtime.backlog).toBe(0);
3680
+ });
3681
+ });
3682
+
2438
3683
  describe("advisor default tools", () => {
2439
3684
  it("defaults to read/grep/glob, a subset of the full grantable tool pool", () => {
2440
3685
  expect([...ADVISOR_DEFAULT_TOOL_NAMES]).toEqual(["read", "grep", "glob"]);
@@ -2760,5 +4005,22 @@ describe("advisor", () => {
2760
4005
  expect(text).toContain("default");
2761
4006
  expect(text).toContain("anthropic/claude-opus");
2762
4007
  });
4008
+ it("shows disabled advisors with a dim circle marker and toggles them in the detail editor", async () => {
4009
+ const uiTheme = await getThemeByName("dark");
4010
+ if (!uiTheme) throw new Error("theme unavailable");
4011
+ setThemeInstance(uiTheme);
4012
+ const overlay = make({
4013
+ advisors: [
4014
+ { name: "Active", model: "x-ai/grok-code-fast:high" },
4015
+ { name: "Disabled", model: "openai/gpt-4", enabled: false },
4016
+ ],
4017
+ });
4018
+ const text = strip(overlay.render(200));
4019
+ // The list shows ● for enabled and ○ for disabled.
4020
+ expect(text).toContain("● Active");
4021
+ expect(text).toContain("○ Disabled");
4022
+ // The preview of the highlighted (first) advisor shows its enabled status.
4023
+ expect(text).toContain("● on");
4024
+ });
2763
4025
  });
2764
4026
  });