opencode-dux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { ForegroundFallbackManager, isRateLimitError } from './index';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
function createMockClient(overrides?: {
|
|
9
|
+
promptAsyncImpl?: (args: unknown) => Promise<unknown>;
|
|
10
|
+
messagesData?: Array<{ info: { role: string }; parts: unknown[] }>;
|
|
11
|
+
}) {
|
|
12
|
+
const promptAsync = mock(async (args: unknown) => {
|
|
13
|
+
if (overrides?.promptAsyncImpl) return overrides.promptAsyncImpl(args);
|
|
14
|
+
return {};
|
|
15
|
+
});
|
|
16
|
+
const abort = mock(async () => ({}));
|
|
17
|
+
const messages = mock(async () => ({
|
|
18
|
+
data: overrides?.messagesData ?? [
|
|
19
|
+
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'hello' }] },
|
|
20
|
+
],
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
client: {
|
|
25
|
+
session: {
|
|
26
|
+
abort,
|
|
27
|
+
messages,
|
|
28
|
+
// promptAsync is cast at runtime - expose via the session object
|
|
29
|
+
promptAsync,
|
|
30
|
+
},
|
|
31
|
+
} as unknown as Parameters<typeof ForegroundFallbackManager>[0],
|
|
32
|
+
mocks: { promptAsync, abort, messages },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeChains(
|
|
37
|
+
overrides?: Record<string, string[]>,
|
|
38
|
+
): Record<string, string[]> {
|
|
39
|
+
return {
|
|
40
|
+
orchestrator: [
|
|
41
|
+
'anthropic/claude-opus-4-5',
|
|
42
|
+
'openai/gpt-4o',
|
|
43
|
+
'google/gemini-2.5-pro',
|
|
44
|
+
],
|
|
45
|
+
explorer: ['openai/gpt-4o-mini', 'anthropic/claude-haiku'],
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// isRateLimitError
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
describe('isRateLimitError', () => {
|
|
55
|
+
test('returns true for 429 status code', () => {
|
|
56
|
+
expect(isRateLimitError({ data: { statusCode: 429 } })).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('returns true for "rate limit" in message', () => {
|
|
60
|
+
expect(isRateLimitError({ message: 'Rate limit exceeded' })).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('returns true for "quota exceeded" in responseBody', () => {
|
|
64
|
+
expect(isRateLimitError({ data: { responseBody: 'quota exceeded' } })).toBe(
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('returns true for "usage exceeded"', () => {
|
|
70
|
+
expect(isRateLimitError({ message: 'usage exceeded' })).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('returns true for "overloaded"', () => {
|
|
74
|
+
expect(isRateLimitError({ message: 'overloaded_error' })).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('returns false for non-rate-limit error', () => {
|
|
78
|
+
expect(isRateLimitError({ message: 'invalid API key' })).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('returns false for null', () => {
|
|
82
|
+
expect(isRateLimitError(null)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('returns false for non-object', () => {
|
|
86
|
+
expect(isRateLimitError('string error')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// ForegroundFallbackManager - disabled
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe('ForegroundFallbackManager (disabled)', () => {
|
|
95
|
+
test('does nothing when enabled=false', async () => {
|
|
96
|
+
const { client, mocks } = createMockClient();
|
|
97
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), false);
|
|
98
|
+
|
|
99
|
+
await mgr.handleEvent({
|
|
100
|
+
type: 'session.error',
|
|
101
|
+
properties: {
|
|
102
|
+
sessionID: 'sess-1',
|
|
103
|
+
error: { message: 'rate limit exceeded' },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// ForegroundFallbackManager - session.error
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe('ForegroundFallbackManager session.error', () => {
|
|
116
|
+
let client: ReturnType<typeof createMockClient>['client'];
|
|
117
|
+
let mocks: ReturnType<typeof createMockClient>['mocks'];
|
|
118
|
+
let mgr: ForegroundFallbackManager;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
({ client, mocks } = createMockClient());
|
|
122
|
+
mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('triggers fallback on rate-limit session.error', async () => {
|
|
126
|
+
// First teach the manager which model is in use for this session
|
|
127
|
+
await mgr.handleEvent({
|
|
128
|
+
type: 'message.updated',
|
|
129
|
+
properties: {
|
|
130
|
+
info: {
|
|
131
|
+
sessionID: 'sess-1',
|
|
132
|
+
providerID: 'anthropic',
|
|
133
|
+
modelID: 'claude-opus-4-5',
|
|
134
|
+
role: 'assistant',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await mgr.handleEvent({
|
|
140
|
+
type: 'session.error',
|
|
141
|
+
properties: {
|
|
142
|
+
sessionID: 'sess-1',
|
|
143
|
+
error: { message: 'Rate limit exceeded' },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(mocks.abort).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
149
|
+
|
|
150
|
+
const call = mocks.promptAsync.mock.calls[0] as [
|
|
151
|
+
{
|
|
152
|
+
path: { id: string };
|
|
153
|
+
body: { model: { providerID: string; modelID: string } };
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
expect(call[0].path.id).toBe('sess-1');
|
|
157
|
+
// Should have picked the next model after anthropic/claude-opus-4-5
|
|
158
|
+
expect(call[0].body.model.providerID).toBe('openai');
|
|
159
|
+
expect(call[0].body.model.modelID).toBe('gpt-4o');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('does nothing when error is not a rate limit', async () => {
|
|
163
|
+
await mgr.handleEvent({
|
|
164
|
+
type: 'session.error',
|
|
165
|
+
properties: {
|
|
166
|
+
sessionID: 'sess-1',
|
|
167
|
+
error: { message: 'invalid request' },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('does nothing when no chain configured for session', async () => {
|
|
175
|
+
const emptyMgr = new ForegroundFallbackManager(client, {}, true);
|
|
176
|
+
await emptyMgr.handleEvent({
|
|
177
|
+
type: 'session.error',
|
|
178
|
+
properties: {
|
|
179
|
+
sessionID: 'sess-1',
|
|
180
|
+
error: { message: 'rate limit exceeded' },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// ForegroundFallbackManager - message.updated
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('ForegroundFallbackManager message.updated', () => {
|
|
193
|
+
test('tracks model from message.updated and falls back on error', async () => {
|
|
194
|
+
const { client, mocks } = createMockClient();
|
|
195
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
196
|
+
|
|
197
|
+
await mgr.handleEvent({
|
|
198
|
+
type: 'message.updated',
|
|
199
|
+
properties: {
|
|
200
|
+
info: {
|
|
201
|
+
sessionID: 'sess-2',
|
|
202
|
+
providerID: 'anthropic',
|
|
203
|
+
modelID: 'claude-opus-4-5',
|
|
204
|
+
error: { message: 'rate limit exceeded' },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
210
|
+
const call = mocks.promptAsync.mock.calls[0] as [
|
|
211
|
+
{
|
|
212
|
+
body: { model: { providerID: string; modelID: string } };
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
expect(call[0].body.model.providerID).toBe('openai');
|
|
216
|
+
expect(call[0].body.model.modelID).toBe('gpt-4o');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('uses agent name from message.updated to select correct chain', async () => {
|
|
220
|
+
const { client, mocks } = createMockClient();
|
|
221
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
222
|
+
|
|
223
|
+
// explorer message with its model
|
|
224
|
+
await mgr.handleEvent({
|
|
225
|
+
type: 'message.updated',
|
|
226
|
+
properties: {
|
|
227
|
+
info: {
|
|
228
|
+
sessionID: 'sess-3',
|
|
229
|
+
agent: 'explorer',
|
|
230
|
+
providerID: 'openai',
|
|
231
|
+
modelID: 'gpt-4o-mini',
|
|
232
|
+
error: { message: 'quota exceeded' },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
238
|
+
const call = mocks.promptAsync.mock.calls[0] as [
|
|
239
|
+
{
|
|
240
|
+
body: { model: { providerID: string; modelID: string } };
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
// explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
|
|
244
|
+
// current=gpt-4o-mini is tried → next = claude-haiku
|
|
245
|
+
expect(call[0].body.model.providerID).toBe('anthropic');
|
|
246
|
+
expect(call[0].body.model.modelID).toBe('claude-haiku');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// ForegroundFallbackManager - session.status retry
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
describe('ForegroundFallbackManager session.status', () => {
|
|
255
|
+
test('triggers fallback on retry status with rate limit message', async () => {
|
|
256
|
+
const { client, mocks } = createMockClient();
|
|
257
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
258
|
+
|
|
259
|
+
// Pre-seed model
|
|
260
|
+
await mgr.handleEvent({
|
|
261
|
+
type: 'message.updated',
|
|
262
|
+
properties: {
|
|
263
|
+
info: {
|
|
264
|
+
sessionID: 'sess-4',
|
|
265
|
+
providerID: 'anthropic',
|
|
266
|
+
modelID: 'claude-opus-4-5',
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await mgr.handleEvent({
|
|
272
|
+
type: 'session.status',
|
|
273
|
+
properties: {
|
|
274
|
+
sessionID: 'sess-4',
|
|
275
|
+
status: { type: 'retry', message: 'usage limit reached, retrying...' },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('ignores session.status with non-rate-limit retry message', async () => {
|
|
283
|
+
const { client, mocks } = createMockClient();
|
|
284
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
285
|
+
|
|
286
|
+
await mgr.handleEvent({
|
|
287
|
+
type: 'session.status',
|
|
288
|
+
properties: {
|
|
289
|
+
sessionID: 'sess-4',
|
|
290
|
+
status: { type: 'retry', message: 'connection timeout, retrying...' },
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// ForegroundFallbackManager - chain exhaustion
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe('ForegroundFallbackManager chain exhaustion', () => {
|
|
303
|
+
test('does not call promptAsync when the only chain model is already the current model', async () => {
|
|
304
|
+
// Scenario: chain = ['openai/gpt-b'], current model IS 'openai/gpt-b'.
|
|
305
|
+
// tryFallback adds 'openai/gpt-b' to tried → chain.find() returns undefined → exhausted.
|
|
306
|
+
const { client, mocks } = createMockClient();
|
|
307
|
+
const mgr = new ForegroundFallbackManager(
|
|
308
|
+
client,
|
|
309
|
+
{ orchestrator: ['openai/gpt-b'] },
|
|
310
|
+
true,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Seed current model as the only chain entry
|
|
314
|
+
await mgr.handleEvent({
|
|
315
|
+
type: 'message.updated',
|
|
316
|
+
properties: {
|
|
317
|
+
info: {
|
|
318
|
+
sessionID: 's',
|
|
319
|
+
providerID: 'openai',
|
|
320
|
+
modelID: 'gpt-b',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Rate limit fires - only model in chain is already current → nothing to fall back to
|
|
326
|
+
await mgr.handleEvent({
|
|
327
|
+
type: 'session.error',
|
|
328
|
+
properties: { sessionID: 's', error: { message: 'rate limit exceeded' } },
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('does not call promptAsync when all chain models have been tried', async () => {
|
|
335
|
+
// Scenario: chain = ['anthropic/claude-a', 'openai/gpt-b'].
|
|
336
|
+
// Current model is 'openai/gpt-b' (the last fallback already in use).
|
|
337
|
+
// tried will contain: 'openai/gpt-b' (current) → chain.find() → 'anthropic/claude-a'
|
|
338
|
+
// would be picked… unless we also mark it tried via a prior switch.
|
|
339
|
+
// Use agent name tracking so we can target the right chain, then seed tried
|
|
340
|
+
// by having the manager go through both models via sequential events
|
|
341
|
+
// (each on a distinct session so dedup does not interfere).
|
|
342
|
+
const { client, mocks } = createMockClient();
|
|
343
|
+
const chain = ['openai/model-x', 'openai/model-y'];
|
|
344
|
+
const mgr = new ForegroundFallbackManager(
|
|
345
|
+
client,
|
|
346
|
+
{ orchestrator: chain },
|
|
347
|
+
true,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// Session A: current model is model-x, which IS in the chain → picks model-y ✓
|
|
351
|
+
await mgr.handleEvent({
|
|
352
|
+
type: 'message.updated',
|
|
353
|
+
properties: {
|
|
354
|
+
info: {
|
|
355
|
+
sessionID: 'sess-exhaust',
|
|
356
|
+
agent: 'orchestrator',
|
|
357
|
+
providerID: 'openai',
|
|
358
|
+
modelID: 'model-x',
|
|
359
|
+
error: { message: 'rate limit exceeded' },
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
364
|
+
|
|
365
|
+
// Session B (fresh session, different ID): only model-y is in chain and it IS
|
|
366
|
+
// the current model → tried gets model-y → chain.find() = undefined → exhausted
|
|
367
|
+
const { client: client2, mocks: mocks2 } = createMockClient();
|
|
368
|
+
const mgr2 = new ForegroundFallbackManager(
|
|
369
|
+
client2,
|
|
370
|
+
{ orchestrator: ['openai/model-y'] }, // single-entry chain already in use
|
|
371
|
+
true,
|
|
372
|
+
);
|
|
373
|
+
await mgr2.handleEvent({
|
|
374
|
+
type: 'message.updated',
|
|
375
|
+
properties: {
|
|
376
|
+
info: {
|
|
377
|
+
sessionID: 'sess-exhaust-2',
|
|
378
|
+
agent: 'orchestrator',
|
|
379
|
+
providerID: 'openai',
|
|
380
|
+
modelID: 'model-y',
|
|
381
|
+
error: { message: 'rate limit exceeded' },
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
expect(mocks2.promptAsync).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// ForegroundFallbackManager - deduplication
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
describe('ForegroundFallbackManager deduplication', () => {
|
|
394
|
+
test('ignores a second trigger within dedup window for same session', async () => {
|
|
395
|
+
const { client, mocks } = createMockClient();
|
|
396
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
397
|
+
|
|
398
|
+
const event = {
|
|
399
|
+
type: 'session.error',
|
|
400
|
+
properties: {
|
|
401
|
+
sessionID: 'sess-dup',
|
|
402
|
+
error: { message: 'rate limit exceeded' },
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
await mgr.handleEvent(event);
|
|
407
|
+
await mgr.handleEvent(event); // immediate second trigger - should be deduped
|
|
408
|
+
|
|
409
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('different sessions are not deduplicated against each other', async () => {
|
|
413
|
+
const { client, mocks } = createMockClient();
|
|
414
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
415
|
+
|
|
416
|
+
await mgr.handleEvent({
|
|
417
|
+
type: 'session.error',
|
|
418
|
+
properties: { sessionID: 'sess-A', error: { message: 'rate limit' } },
|
|
419
|
+
});
|
|
420
|
+
await mgr.handleEvent({
|
|
421
|
+
type: 'session.error',
|
|
422
|
+
properties: { sessionID: 'sess-B', error: { message: 'rate limit' } },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// ForegroundFallbackManager - subagent.session.created
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
describe('ForegroundFallbackManager subagent.session.created', () => {
|
|
434
|
+
test('records agent name from subagent.session.created when agentName provided', async () => {
|
|
435
|
+
const { client, mocks } = createMockClient();
|
|
436
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
437
|
+
|
|
438
|
+
// Register the session as 'explorer' via subagent creation event
|
|
439
|
+
await mgr.handleEvent({
|
|
440
|
+
type: 'subagent.session.created',
|
|
441
|
+
properties: { sessionID: 'sub-1', agentName: 'explorer' },
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Now trigger rate limit - should use explorer's chain
|
|
445
|
+
await mgr.handleEvent({
|
|
446
|
+
type: 'session.error',
|
|
447
|
+
properties: { sessionID: 'sub-1', error: { message: 'rate limit' } },
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
451
|
+
const call = mocks.promptAsync.mock.calls[0] as [
|
|
452
|
+
{
|
|
453
|
+
body: { model: { providerID: string; modelID: string } };
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
// explorer chain: ['openai/gpt-4o-mini', 'anthropic/claude-haiku']
|
|
457
|
+
// no current model tracked → first untried = openai/gpt-4o-mini
|
|
458
|
+
expect(call[0].body.model.providerID).toBe('openai');
|
|
459
|
+
expect(call[0].body.model.modelID).toBe('gpt-4o-mini');
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
// ForegroundFallbackManager - session.deleted cleanup
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
describe('ForegroundFallbackManager session.deleted', () => {
|
|
468
|
+
test('cleans up session state on session.deleted preventing memory leaks', async () => {
|
|
469
|
+
const { client, mocks } = createMockClient();
|
|
470
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
471
|
+
|
|
472
|
+
// Populate all maps for this session
|
|
473
|
+
await mgr.handleEvent({
|
|
474
|
+
type: 'message.updated',
|
|
475
|
+
properties: {
|
|
476
|
+
info: {
|
|
477
|
+
sessionID: 'sess-del',
|
|
478
|
+
agent: 'orchestrator',
|
|
479
|
+
providerID: 'anthropic',
|
|
480
|
+
modelID: 'claude-opus-4-5',
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Delete the session
|
|
486
|
+
await mgr.handleEvent({
|
|
487
|
+
type: 'session.deleted',
|
|
488
|
+
properties: { sessionID: 'sess-del' },
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// After deletion, a new rate-limit on the same ID should behave as a fresh
|
|
492
|
+
// session (no prior model known → uses chain from start, dedup cleared)
|
|
493
|
+
await mgr.handleEvent({
|
|
494
|
+
type: 'session.error',
|
|
495
|
+
properties: {
|
|
496
|
+
sessionID: 'sess-del',
|
|
497
|
+
error: { message: 'rate limit exceeded' },
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Should have triggered (dedup was cleared by session.deleted)
|
|
502
|
+
// and should pick the first chain model (no current model seed after deletion)
|
|
503
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
504
|
+
const call = mocks.promptAsync.mock.calls[0] as [
|
|
505
|
+
{ body: { model: { providerID: string; modelID: string } } },
|
|
506
|
+
];
|
|
507
|
+
// orchestrator chain: ['anthropic/claude-opus-4-5', 'openai/gpt-4o', 'google/gemini-2.5-pro']
|
|
508
|
+
// no current model → first untried = anthropic/claude-opus-4-5
|
|
509
|
+
expect(call[0].body.model.providerID).toBe('anthropic');
|
|
510
|
+
expect(call[0].body.model.modelID).toBe('claude-opus-4-5');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('ignores session.deleted with no sessionID', async () => {
|
|
514
|
+
const { client } = createMockClient();
|
|
515
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
516
|
+
// Should not throw
|
|
517
|
+
await expect(
|
|
518
|
+
mgr.handleEvent({ type: 'session.deleted', properties: {} }),
|
|
519
|
+
).resolves.toBeUndefined();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('cleans up state using info.id shape (top-level session deletion)', async () => {
|
|
523
|
+
// OpenCode emits { properties: { info: { id } } } for top-level sessions
|
|
524
|
+
// and { properties: { sessionID } } for subagent sessions. Both must clean up.
|
|
525
|
+
const { client, mocks } = createMockClient();
|
|
526
|
+
const mgr = new ForegroundFallbackManager(client, makeChains(), true);
|
|
527
|
+
|
|
528
|
+
// Seed state for the session
|
|
529
|
+
await mgr.handleEvent({
|
|
530
|
+
type: 'message.updated',
|
|
531
|
+
properties: {
|
|
532
|
+
info: {
|
|
533
|
+
sessionID: 'sess-info-del',
|
|
534
|
+
agent: 'orchestrator',
|
|
535
|
+
providerID: 'anthropic',
|
|
536
|
+
modelID: 'claude-opus-4-5',
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Delete via the info.id shape
|
|
542
|
+
await mgr.handleEvent({
|
|
543
|
+
type: 'session.deleted',
|
|
544
|
+
properties: { info: { id: 'sess-info-del' } },
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// State is cleared: a new rate-limit on same ID should behave as fresh session
|
|
548
|
+
await mgr.handleEvent({
|
|
549
|
+
type: 'session.error',
|
|
550
|
+
properties: {
|
|
551
|
+
sessionID: 'sess-info-del',
|
|
552
|
+
error: { message: 'rate limit exceeded' },
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Triggered (dedup was cleared by deletion)
|
|
557
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// ForegroundFallbackManager - resolveChain correctness
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
|
|
566
|
+
test('does not use another agent chain when known agent has no configured chain', async () => {
|
|
567
|
+
// oracle has no chain in runtimeChains; without the fix resolveChain would
|
|
568
|
+
// fall through to the cross-agent "last resort" and pick a model from
|
|
569
|
+
// orchestrator's chain - re-prompting oracle with an orchestrator model.
|
|
570
|
+
const { client, mocks } = createMockClient();
|
|
571
|
+
const mgr = new ForegroundFallbackManager(
|
|
572
|
+
client,
|
|
573
|
+
{
|
|
574
|
+
// oracle intentionally absent - no chain configured
|
|
575
|
+
orchestrator: ['openai/gpt-4o', 'google/gemini-2.5-pro'],
|
|
576
|
+
},
|
|
577
|
+
true,
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
await mgr.handleEvent({
|
|
581
|
+
type: 'message.updated',
|
|
582
|
+
properties: {
|
|
583
|
+
info: {
|
|
584
|
+
sessionID: 'oracle-sess',
|
|
585
|
+
agent: 'oracle', // agent IS known
|
|
586
|
+
providerID: 'anthropic',
|
|
587
|
+
modelID: 'claude-opus-4-5',
|
|
588
|
+
error: { message: 'rate limit exceeded' },
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// oracle has no chain → should not fall back at all
|
|
594
|
+
expect(mocks.promptAsync).not.toHaveBeenCalled();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('uses cross-agent last-resort only when agent name is unknown', async () => {
|
|
598
|
+
// When the agent name is genuinely unknown AND current model is not in any
|
|
599
|
+
// chain, the last-resort flattened chain is acceptable.
|
|
600
|
+
const { client, mocks } = createMockClient();
|
|
601
|
+
const mgr = new ForegroundFallbackManager(
|
|
602
|
+
client,
|
|
603
|
+
{ orchestrator: ['openai/gpt-4o'] },
|
|
604
|
+
true,
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
// No agent name tracked, no model tracked - triggers session.error
|
|
608
|
+
await mgr.handleEvent({
|
|
609
|
+
type: 'session.error',
|
|
610
|
+
properties: {
|
|
611
|
+
sessionID: 'unknown-agent-sess',
|
|
612
|
+
error: { message: 'rate limit exceeded' },
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Falls through to last-resort → picks first model from any chain
|
|
617
|
+
expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
|
|
618
|
+
const call = mocks.promptAsync.mock.calls[0] as [
|
|
619
|
+
{ body: { model: { providerID: string; modelID: string } } },
|
|
620
|
+
];
|
|
621
|
+
expect(call[0].body.model.providerID).toBe('openai');
|
|
622
|
+
expect(call[0].body.model.modelID).toBe('gpt-4o');
|
|
623
|
+
});
|
|
624
|
+
});
|