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.
Files changed (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. 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
+ });