@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.
- package/CHANGELOG.md +92 -20
- package/dist/cli.js +3485 -3448
- package/dist/types/advisor/config.d.ts +14 -6
- package/dist/types/advisor/runtime.d.ts +20 -11
- package/dist/types/autolearn/controller.d.ts +1 -0
- package/dist/types/config/model-discovery.d.ts +17 -0
- package/dist/types/config/model-resolver.d.ts +6 -2
- package/dist/types/config/settings-schema.d.ts +32 -7
- package/dist/types/config/settings.d.ts +50 -0
- package/dist/types/cursor.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +5 -2
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
- package/dist/types/extensibility/extensions/runner.d.ts +7 -0
- package/dist/types/extensibility/extensions/types.d.ts +18 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
- package/dist/types/extensibility/shared-events.d.ts +6 -0
- package/dist/types/extensibility/utils.d.ts +2 -2
- package/dist/types/mcp/transports/stdio.d.ts +13 -1
- package/dist/types/modes/components/advisor-config.d.ts +8 -1
- package/dist/types/modes/components/hook-editor.d.ts +7 -0
- package/dist/types/modes/components/model-hub.d.ts +5 -2
- package/dist/types/modes/components/session-selector.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +8 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/print-mode.d.ts +1 -1
- package/dist/types/modes/warp-events.d.ts +24 -0
- package/dist/types/modes/warp-events.test.d.ts +1 -0
- package/dist/types/plan-mode/model-transition.d.ts +47 -0
- package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
- package/dist/types/registry/agent-lifecycle.d.ts +26 -1
- package/dist/types/sdk.d.ts +13 -2
- package/dist/types/session/agent-session.d.ts +34 -10
- package/dist/types/session/session-history-format.d.ts +10 -0
- package/dist/types/session/session-manager.d.ts +14 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
- package/dist/types/task/label.d.ts +1 -1
- package/dist/types/telemetry-export.d.ts +34 -9
- package/dist/types/tools/approval.d.ts +8 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/essential-tools.d.ts +29 -0
- package/dist/types/tools/image-gen.d.ts +2 -1
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/xdev.d.ts +11 -2
- package/dist/types/utils/title-generator.d.ts +2 -1
- package/dist/types/web/search/providers/kimi.d.ts +4 -1
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +21 -16
- package/src/advisor/__tests__/advisor.test.ts +1304 -42
- package/src/advisor/__tests__/config.test.ts +58 -2
- package/src/advisor/config.ts +76 -24
- package/src/advisor/runtime.ts +445 -92
- package/src/autolearn/controller.ts +23 -28
- package/src/cli.ts +5 -1
- package/src/config/model-discovery.ts +81 -21
- package/src/config/model-registry.ts +25 -6
- package/src/config/model-resolver.ts +14 -7
- package/src/config/settings-schema.ts +42 -6
- package/src/config/settings.ts +405 -25
- package/src/cursor.ts +20 -3
- package/src/debug/report-bundle.ts +40 -4
- package/src/discovery/helpers.ts +28 -5
- package/src/exec/bash-executor.ts +14 -5
- package/src/extensibility/custom-tools/loader.ts +3 -3
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/extensibility/extensions/loader.ts +3 -3
- package/src/extensibility/extensions/managed-timers.ts +83 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/types.ts +18 -0
- package/src/extensibility/extensions/wrapper.ts +2 -1
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
- package/src/extensibility/plugins/manager.ts +2 -2
- package/src/extensibility/shared-events.ts +6 -0
- package/src/extensibility/utils.ts +91 -25
- package/src/irc/bus.ts +22 -3
- package/src/launch/broker.ts +3 -2
- package/src/launch/client.ts +2 -2
- package/src/launch/presence.ts +2 -2
- package/src/lsp/client.ts +1 -1
- package/src/main.ts +11 -8
- package/src/mcp/manager.ts +9 -3
- package/src/mcp/transports/stdio.ts +103 -23
- package/src/modes/components/advisor-config.ts +65 -3
- package/src/modes/components/ask-dialog.ts +1 -1
- package/src/modes/components/hook-editor.ts +18 -3
- package/src/modes/components/model-hub.ts +138 -42
- package/src/modes/components/session-selector.ts +4 -0
- package/src/modes/components/status-line/component.test.ts +1 -0
- package/src/modes/components/status-line/segments.ts +21 -6
- package/src/modes/controllers/command-controller.ts +167 -47
- package/src/modes/controllers/event-controller.ts +5 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -22
- package/src/modes/controllers/input-controller.ts +12 -12
- package/src/modes/controllers/selector-controller.ts +191 -31
- package/src/modes/interactive-mode.ts +139 -54
- package/src/modes/print-mode.ts +3 -3
- package/src/modes/rpc/host-tools.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +19 -4
- package/src/modes/warp-events.test.ts +794 -0
- package/src/modes/warp-events.ts +232 -0
- package/src/plan-mode/model-transition.test.ts +60 -0
- package/src/plan-mode/model-transition.ts +51 -0
- package/src/registry/agent-lifecycle.ts +133 -18
- package/src/sdk.ts +221 -42
- package/src/session/agent-session.ts +1285 -348
- package/src/session/session-history-format.ts +20 -5
- package/src/session/session-manager.ts +48 -0
- package/src/slash-commands/builtin-registry.ts +7 -0
- package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
- package/src/task/executor.ts +1 -1
- package/src/task/label.ts +2 -0
- package/src/telemetry-export.ts +453 -97
- package/src/tools/approval.ts +11 -0
- package/src/tools/bash.ts +71 -38
- package/src/tools/essential-tools.ts +45 -0
- package/src/tools/gh.ts +169 -2
- package/src/tools/image-gen.ts +69 -7
- package/src/tools/index.ts +7 -5
- package/src/tools/read.ts +48 -3
- package/src/tools/write.ts +22 -4
- package/src/tools/xdev.ts +14 -3
- package/src/utils/title-generator.ts +15 -4
- package/src/web/search/providers/kimi.ts +18 -12
- 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("
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
1801
|
-
//
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
//
|
|
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
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
});
|