kimaki 0.26.0 → 0.28.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/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -0,0 +1,537 @@
1
+ // Tests for model ID parsing, cached provider.list wrapping, and validation.
2
+
3
+ import { mkdtemp, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
+ import {
8
+ addAccount,
9
+ markCooldown,
10
+ savePreset,
11
+ setLiveRoute,
12
+ type StoredAccount,
13
+ } from '@subrouter/cli'
14
+ import { InvalidModelError } from '../errors.js'
15
+ import {
16
+ clearModelListCache,
17
+ formatDisplayedModelId,
18
+ getProviderModelName,
19
+ listModels,
20
+ parseModelId,
21
+ resolveDisplayedModelId,
22
+ validateCliModelOption,
23
+ validateModelId,
24
+ validateModelIdAgainstList,
25
+ type ListedModel,
26
+ } from './model-utils.js'
27
+
28
+ function oauthAccount(overrides: Partial<StoredAccount> = {}): StoredAccount {
29
+ return {
30
+ type: 'oauth',
31
+ refresh: 'refresh-1',
32
+ access: 'access-1',
33
+ expires: Date.now() + 1_000_000_000,
34
+ addedAt: 1000,
35
+ lastUsed: 1000,
36
+ ...overrides,
37
+ }
38
+ }
39
+
40
+ const listed: ListedModel[] = [
41
+ {
42
+ providerID: 'anthropic',
43
+ modelID: 'claude-opus-4-6',
44
+ name: 'Claude Opus 4.6',
45
+ connected: true,
46
+ },
47
+ {
48
+ providerID: 'anthropic',
49
+ modelID: 'claude-sonnet-4-6',
50
+ name: 'Claude Sonnet 4.6',
51
+ connected: true,
52
+ },
53
+ {
54
+ providerID: 'openai',
55
+ modelID: 'gpt-5.4',
56
+ name: 'GPT-5.4',
57
+ connected: false,
58
+ },
59
+ ]
60
+
61
+ afterEach(() => {
62
+ clearModelListCache()
63
+ })
64
+
65
+ describe('getProviderModelName', () => {
66
+ const providers = [
67
+ {
68
+ id: 'subrouter',
69
+ models: {
70
+ build: { name: 'build (claude-opus-4-6)' },
71
+ default: { name: 'default' },
72
+ },
73
+ },
74
+ {
75
+ id: 'deterministic-provider',
76
+ models: {
77
+ 'deterministic-v2': { name: 'deterministic-v2' },
78
+ },
79
+ },
80
+ ]
81
+
82
+ test('uses the sdk display name when subrouter adds the live model', () => {
83
+ expect(
84
+ getProviderModelName({
85
+ providers,
86
+ providerID: 'subrouter',
87
+ modelID: 'build',
88
+ }),
89
+ ).toMatchInlineSnapshot(`"build (claude-opus-4-6)"`)
90
+ })
91
+
92
+ test('falls back to the model id when name is missing', () => {
93
+ expect(
94
+ getProviderModelName({
95
+ providers: [{ id: 'subrouter', models: { build: {} } }],
96
+ providerID: 'subrouter',
97
+ modelID: 'build',
98
+ }),
99
+ ).toMatchInlineSnapshot(`"build"`)
100
+ })
101
+
102
+ test('returns undefined when provider or model is missing', () => {
103
+ expect(
104
+ getProviderModelName({
105
+ providers,
106
+ providerID: 'subrouter',
107
+ modelID: 'missing',
108
+ }),
109
+ ).toBeUndefined()
110
+ expect(
111
+ getProviderModelName({
112
+ providers,
113
+ providerID: 'openai',
114
+ modelID: 'gpt-5.4',
115
+ }),
116
+ ).toBeUndefined()
117
+ expect(
118
+ getProviderModelName({
119
+ providers,
120
+ }),
121
+ ).toBeUndefined()
122
+ })
123
+ })
124
+
125
+ describe('formatDisplayedModelId', () => {
126
+ test('keeps provider/id when the sdk name matches the model id', () => {
127
+ expect(
128
+ formatDisplayedModelId({
129
+ providerID: 'deterministic-provider',
130
+ modelID: 'deterministic-v2',
131
+ name: 'deterministic-v2',
132
+ }),
133
+ ).toMatchInlineSnapshot(`"deterministic-provider/deterministic-v2"`)
134
+ })
135
+
136
+ test('uses the sdk name so subrouter live model shows in parentheses', () => {
137
+ expect(
138
+ formatDisplayedModelId({
139
+ providerID: 'subrouter',
140
+ modelID: 'build',
141
+ name: 'build (claude-opus-4-6)',
142
+ }),
143
+ ).toMatchInlineSnapshot(`"subrouter/build (claude-opus-4-6)"`)
144
+ })
145
+
146
+ test('falls back to provider/id when name is missing', () => {
147
+ expect(
148
+ formatDisplayedModelId({
149
+ providerID: 'subrouter',
150
+ modelID: 'build',
151
+ }),
152
+ ).toMatchInlineSnapshot(`"subrouter/build"`)
153
+ })
154
+
155
+ test('keeps provider/id when sdk name is a pretty label', () => {
156
+ expect(
157
+ formatDisplayedModelId({
158
+ providerID: 'anthropic',
159
+ modelID: 'claude-opus-4-6',
160
+ name: 'Claude Opus 4.6',
161
+ }),
162
+ ).toMatchInlineSnapshot(`"anthropic/claude-opus-4-6"`)
163
+ })
164
+ })
165
+
166
+ describe('resolveDisplayedModelId', () => {
167
+ let home: string
168
+ const previousHome = process.env.SUBROUTER_HOME
169
+
170
+ beforeEach(async () => {
171
+ home = await mkdtemp(path.join(tmpdir(), 'kimaki-subrouter-model-'))
172
+ process.env.SUBROUTER_HOME = home
173
+ })
174
+
175
+ afterEach(async () => {
176
+ if (previousHome === undefined) delete process.env.SUBROUTER_HOME
177
+ else process.env.SUBROUTER_HOME = previousHome
178
+ await rm(home, { recursive: true, force: true })
179
+ })
180
+
181
+ test('uses subrouter sdk name with parentheses', async () => {
182
+ expect(
183
+ await resolveDisplayedModelId({
184
+ providers: [
185
+ {
186
+ id: 'subrouter',
187
+ models: { build: { name: 'build (claude-opus-4-6)' } },
188
+ },
189
+ ],
190
+ providerID: 'subrouter',
191
+ modelID: 'build',
192
+ }),
193
+ ).toMatchInlineSnapshot(`"subrouter/build (claude-opus-4-6)"`)
194
+ })
195
+
196
+ test('shows the live subrouter candidate, not the preset name', async () => {
197
+ await addAccount({
198
+ provider: 'anthropic',
199
+ account: oauthAccount({ email: 'a@x.com' }),
200
+ })
201
+ await addAccount({
202
+ provider: 'opencode-go',
203
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
204
+ })
205
+ await savePreset({
206
+ name: 'build',
207
+ models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
208
+ })
209
+
210
+ expect(
211
+ await resolveDisplayedModelId({
212
+ providers: [{ id: 'subrouter', models: { build: { name: 'build' } } }],
213
+ providerID: 'subrouter',
214
+ modelID: 'build',
215
+ }),
216
+ ).toMatchInlineSnapshot(`"subrouter/build (anthropic/claude-fake)"`)
217
+ })
218
+
219
+ test('shows the cooldown fallback candidate as the current model', async () => {
220
+ const anthropic = oauthAccount({ email: 'a@x.com' })
221
+ await addAccount({ provider: 'anthropic', account: anthropic })
222
+ await addAccount({
223
+ provider: 'opencode-go',
224
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
225
+ })
226
+ await savePreset({
227
+ name: 'build',
228
+ models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
229
+ })
230
+ await markCooldown({
231
+ provider: 'anthropic',
232
+ account: anthropic,
233
+ untilMs: Date.now() + 60_000,
234
+ })
235
+
236
+ expect(
237
+ await resolveDisplayedModelId({
238
+ providers: [{ id: 'subrouter', models: { build: { name: 'build' } } }],
239
+ providerID: 'subrouter',
240
+ modelID: 'build',
241
+ }),
242
+ ).toMatchInlineSnapshot(`"subrouter/build (opencode-go/fake-model)"`)
243
+ })
244
+
245
+ test('shows the in-flight session route, not the first free preset model', async () => {
246
+ await addAccount({
247
+ provider: 'anthropic',
248
+ account: oauthAccount({ email: 'a@x.com' }),
249
+ })
250
+ await addAccount({
251
+ provider: 'opencode-go',
252
+ account: { type: 'api', key: 'zen-key', addedAt: 1, lastUsed: 1 },
253
+ })
254
+ await savePreset({
255
+ name: 'build',
256
+ models: ['anthropic/claude-fake', 'opencode-go/fake-model'],
257
+ })
258
+ await setLiveRoute({
259
+ sessionID: 'ses_1',
260
+ preset: 'build',
261
+ provider: 'opencode-go',
262
+ modelId: 'fake-model',
263
+ })
264
+
265
+ expect(
266
+ await resolveDisplayedModelId({
267
+ providers: [{ id: 'subrouter', models: { build: { name: 'build' } } }],
268
+ providerID: 'subrouter',
269
+ modelID: 'build',
270
+ sessionID: 'ses_1',
271
+ }),
272
+ ).toMatchInlineSnapshot(`"subrouter/build (opencode-go/fake-model)"`)
273
+ expect(
274
+ await resolveDisplayedModelId({
275
+ providers: [{ id: 'subrouter', models: { build: { name: 'build' } } }],
276
+ providerID: 'subrouter',
277
+ modelID: 'build',
278
+ }),
279
+ ).toMatchInlineSnapshot(`"subrouter/build (anthropic/claude-fake)"`)
280
+ })
281
+ })
282
+
283
+ describe('parseModelId', () => {
284
+ test('splits provider/model', () => {
285
+ expect(parseModelId('anthropic/claude-opus-4-6')).toMatchInlineSnapshot(`
286
+ {
287
+ "modelID": "claude-opus-4-6",
288
+ "providerID": "anthropic",
289
+ }
290
+ `)
291
+ })
292
+
293
+ test('keeps slashes inside the model id', () => {
294
+ expect(parseModelId('opencode/glm-5/free')).toMatchInlineSnapshot(`
295
+ {
296
+ "modelID": "glm-5/free",
297
+ "providerID": "opencode",
298
+ }
299
+ `)
300
+ })
301
+
302
+ test('rejects missing slash, empty provider, and empty model', () => {
303
+ expect(parseModelId('claude-opus-4-6')).toBeUndefined()
304
+ expect(parseModelId('/claude-opus-4-6')).toBeUndefined()
305
+ expect(parseModelId('anthropic/')).toBeUndefined()
306
+ expect(parseModelId('')).toBeUndefined()
307
+ })
308
+ })
309
+
310
+ describe('validateModelIdAgainstList', () => {
311
+ test('accepts a connected listed model', () => {
312
+ expect(
313
+ validateModelIdAgainstList({
314
+ model: 'anthropic/claude-opus-4-6',
315
+ models: listed,
316
+ }),
317
+ ).toMatchInlineSnapshot(`
318
+ {
319
+ "modelID": "claude-opus-4-6",
320
+ "providerID": "anthropic",
321
+ }
322
+ `)
323
+ })
324
+
325
+ test('rejects bad format', () => {
326
+ const result = validateModelIdAgainstList({
327
+ model: 'claude-opus-4-6',
328
+ models: listed,
329
+ })
330
+ expect(result).toBeInstanceOf(InvalidModelError)
331
+ if (result instanceof InvalidModelError) {
332
+ expect(result.model).toBe('claude-opus-4-6')
333
+ expect(result.reason).toContain('provider/model')
334
+ }
335
+ })
336
+
337
+ test('rejects unknown model and lists siblings', () => {
338
+ const result = validateModelIdAgainstList({
339
+ model: 'anthropic/does-not-exist',
340
+ models: listed,
341
+ })
342
+ expect(result).toBeInstanceOf(InvalidModelError)
343
+ if (result instanceof InvalidModelError) {
344
+ expect(result.message).toContain('anthropic/does-not-exist')
345
+ expect(result.message).toContain('anthropic/claude-opus-4-6')
346
+ expect(result.message).toContain('anthropic/claude-sonnet-4-6')
347
+ }
348
+ })
349
+
350
+ test('rejects disconnected provider', () => {
351
+ const result = validateModelIdAgainstList({
352
+ model: 'openai/gpt-5.4',
353
+ models: listed,
354
+ })
355
+ expect(result).toBeInstanceOf(InvalidModelError)
356
+ if (result instanceof InvalidModelError) {
357
+ expect(result.message).toContain('not connected')
358
+ expect(result.message).toContain('openai')
359
+ }
360
+ })
361
+ })
362
+
363
+ function fakeGetClient(options?: {
364
+ calls?: { count: number }
365
+ failOnce?: boolean
366
+ }) {
367
+ let failed = false
368
+ return () => ({
369
+ provider: {
370
+ list: async () => {
371
+ if (options?.calls) options.calls.count += 1
372
+ if (options?.failOnce && !failed) {
373
+ failed = true
374
+ throw new Error('provider.list failed')
375
+ }
376
+ return {
377
+ data: {
378
+ all: [
379
+ {
380
+ id: 'anthropic',
381
+ models: {
382
+ 'claude-opus-4-6': { name: 'Claude Opus 4.6' },
383
+ },
384
+ },
385
+ ],
386
+ connected: ['anthropic'],
387
+ default: {},
388
+ },
389
+ }
390
+ },
391
+ },
392
+ })
393
+ }
394
+
395
+ describe('listModels', () => {
396
+ test('returns listed models from provider.list', async () => {
397
+ const models = await listModels({
398
+ getClient: fakeGetClient() as never,
399
+ directory: '/tmp/project-a',
400
+ })
401
+ expect(models).not.toBeInstanceOf(Error)
402
+ if (models instanceof Error) throw models
403
+ expect(models).toMatchInlineSnapshot(`
404
+ [
405
+ {
406
+ "connected": true,
407
+ "modelID": "claude-opus-4-6",
408
+ "name": "Claude Opus 4.6",
409
+ "providerID": "anthropic",
410
+ },
411
+ ]
412
+ `)
413
+ })
414
+
415
+ test('memoizes provider.list per directory', async () => {
416
+ const calls = { count: 0 }
417
+ const getClient = fakeGetClient({ calls }) as never
418
+ const first = await listModels({ getClient, directory: '/tmp/project-a' })
419
+ const second = await listModels({ getClient, directory: '/tmp/project-a' })
420
+ expect(first).not.toBeInstanceOf(Error)
421
+ expect(second).not.toBeInstanceOf(Error)
422
+ expect(calls.count).toBe(1)
423
+ })
424
+
425
+ test('does not share cache across directories', async () => {
426
+ const calls = { count: 0 }
427
+ const getClient = fakeGetClient({ calls }) as never
428
+ const first = await listModels({ getClient, directory: '/tmp/project-a' })
429
+ const second = await listModels({ getClient, directory: '/tmp/project-b' })
430
+ expect(first).not.toBeInstanceOf(Error)
431
+ expect(second).not.toBeInstanceOf(Error)
432
+ expect(calls.count).toBe(2)
433
+ })
434
+
435
+ test('does not cache failed fetches', async () => {
436
+ const calls = { count: 0 }
437
+ const getClient = fakeGetClient({ calls, failOnce: true }) as never
438
+ const first = await listModels({ getClient, directory: '/tmp/project-a' })
439
+ expect(first).toBeInstanceOf(Error)
440
+ const second = await listModels({ getClient, directory: '/tmp/project-a' })
441
+ expect(second).not.toBeInstanceOf(Error)
442
+ expect(calls.count).toBe(2)
443
+ })
444
+
445
+ test('does not restore a pending result after the cache is cleared', async () => {
446
+ const calls = { count: 0 }
447
+ const firstResponse = Promise.withResolvers<{
448
+ data: {
449
+ all: Array<{ id: string; models: Record<string, { name: string }> }>
450
+ connected: string[]
451
+ default: Record<string, string>
452
+ }
453
+ }>()
454
+ const getClient = (() => ({
455
+ provider: {
456
+ list: () => {
457
+ calls.count += 1
458
+ if (calls.count === 1) return firstResponse.promise
459
+ return Promise.resolve({
460
+ data: {
461
+ all: [{ id: 'openai', models: { 'gpt-5.5': { name: 'GPT-5.5' } } }],
462
+ connected: ['openai'],
463
+ default: {},
464
+ },
465
+ })
466
+ },
467
+ },
468
+ })) as never
469
+
470
+ const pending = listModels({ getClient, directory: '/tmp/project-a' })
471
+ clearModelListCache()
472
+ firstResponse.resolve({
473
+ data: {
474
+ all: [
475
+ {
476
+ id: 'anthropic',
477
+ models: { 'claude-opus-4-6': { name: 'Claude Opus 4.6' } },
478
+ },
479
+ ],
480
+ connected: ['anthropic'],
481
+ default: {},
482
+ },
483
+ })
484
+ await pending
485
+
486
+ const refreshed = await listModels({ getClient, directory: '/tmp/project-a' })
487
+ expect({ calls: calls.count, refreshed }).toMatchInlineSnapshot(`
488
+ {
489
+ "calls": 2,
490
+ "refreshed": [
491
+ {
492
+ "connected": true,
493
+ "modelID": "gpt-5.5",
494
+ "name": "GPT-5.5",
495
+ "providerID": "openai",
496
+ },
497
+ ],
498
+ }
499
+ `)
500
+ })
501
+ })
502
+
503
+ describe('validateCliModelOption', () => {
504
+ test('skips empty model', async () => {
505
+ expect(await validateCliModelOption({ model: undefined })).toBeUndefined()
506
+ expect(await validateCliModelOption({ model: '' })).toBeUndefined()
507
+ })
508
+
509
+ test('rejects bad format without starting opencode', async () => {
510
+ const result = await validateCliModelOption({ model: 'claude-opus-4-6' })
511
+ expect(result).toBeInstanceOf(InvalidModelError)
512
+ })
513
+ })
514
+
515
+ describe('validateModelId', () => {
516
+ test('validates against the cached list', async () => {
517
+ const getClient = fakeGetClient() as never
518
+ const ok = await validateModelId({
519
+ model: 'anthropic/claude-opus-4-6',
520
+ getClient,
521
+ directory: '/tmp/project-a',
522
+ })
523
+ expect(ok).toMatchInlineSnapshot(`
524
+ {
525
+ "modelID": "claude-opus-4-6",
526
+ "providerID": "anthropic",
527
+ }
528
+ `)
529
+
530
+ const bad = await validateModelId({
531
+ model: 'anthropic/missing',
532
+ getClient,
533
+ directory: '/tmp/project-a',
534
+ })
535
+ expect(bad).toBeInstanceOf(InvalidModelError)
536
+ })
537
+ })