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
@@ -13,20 +13,21 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import url from 'node:url';
15
15
  import { describe, beforeAll, afterAll, test, expect, } from 'vitest';
16
- import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder } from 'discord.js';
16
+ import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes } from 'discord.js';
17
17
  import { DigitalDiscord } from 'discord-digital-twin/src';
18
18
  import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
19
19
  import { setDataDir } from './config.js';
20
20
  import { store } from './store.js';
21
21
  import { startDiscordBot } from './discord-bot.js';
22
- import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionModel, getSessionAgent, getChannelAgent, setSessionModel, } from './database.js';
22
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, getThreadSession, getSessionModel, getSessionAgent, getChannelAgent, getChannelModel, setSessionModel, } from './database.js';
23
23
  import { getDb } from './db.js';
24
24
  import * as orm from 'drizzle-orm';
25
25
  import * as schema from './schema.js';
26
26
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
27
27
  import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
28
- import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
28
+ import { chooseLockPort, cleanupTestSessions, initTestGitRepo, isFooterMessage, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
29
29
  import { buildQuickAgentCommandDescription } from './commands/agent.js';
30
+ import { buildQuickAgentSlashCommand } from './discord-command-registration.js';
30
31
  const TEST_USER_ID = '200000000000000920';
31
32
  const TEXT_CHANNEL_ID = '200000000000000921';
32
33
  const AGENT_MODEL = 'agent-model-v2';
@@ -265,6 +266,9 @@ describe('agent model resolution', () => {
265
266
  }
266
267
  providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
267
268
  providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
269
+ Object.assign(providerConfig.models[PLAN_AGENT_MODEL], {
270
+ variants: { high: {}, max: {} },
271
+ });
268
272
  providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
269
273
  fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
270
274
  // Leading /command detection only rewrites when registeredUserCommands is set
@@ -316,18 +320,13 @@ describe('agent model resolution', () => {
316
320
  // Register quick agent slash commands so /plan-agent and /test-agent-agent
317
321
  // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
318
322
  const agentCommands = ['test-agent', 'plan', 'plain'].map((agentName) => {
319
- return new SlashCommandBuilder()
320
- .setName(`${agentName}-agent`)
321
- .setDescription(buildQuickAgentCommandDescription({
322
- agentName,
323
- description: `Switch to ${agentName} agent`,
324
- }))
325
- .setDMPermission(false)
326
- .addStringOption((opt) => opt
327
- .setName('prompt')
328
- .setDescription('Send a prompt with this agent')
329
- .setRequired(false))
330
- .toJSON();
323
+ return buildQuickAgentSlashCommand({
324
+ commandName: `${agentName}-agent`,
325
+ description: buildQuickAgentCommandDescription({
326
+ agentName,
327
+ description: `Switch to ${agentName} agent`,
328
+ }),
329
+ }).toJSON();
331
330
  });
332
331
  const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
333
332
  await rest.put(Routes.applicationGuildCommands(discord.botUserId, discord.guildId), { body: agentCommands });
@@ -394,15 +393,15 @@ describe('agent model resolution', () => {
394
393
  // Find the footer message (starts with * italic)
395
394
  const footerMessage = messages.find((message) => {
396
395
  return (message.author.id === discord.botUserId &&
397
- message.content.startsWith('*'));
396
+ isFooterMessage({ message, botUserId: discord.botUserId }));
398
397
  });
399
398
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
400
399
  "--- from: user (agent-model-tester)
401
400
  Reply with exactly: agent-model-check
402
401
  --- from: assistant (TestBot)
403
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
404
- ⬥ ok
405
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
402
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
403
+ > ok
404
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
406
405
  `);
407
406
  expect(footerMessage).toBeDefined();
408
407
  if (!footerMessage) {
@@ -444,9 +443,9 @@ describe('agent model resolution', () => {
444
443
  "--- from: user (agent-model-tester)
445
444
  Reply with exactly: system-context-check
446
445
  --- from: assistant (TestBot)
447
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
448
- ⬥ system-context-ok
449
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
446
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
447
+ > system-context-ok
448
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
450
449
  `);
451
450
  }, 15_000);
452
451
  test('session.command path includes kimaki system prompt on first message', async () => {
@@ -514,7 +513,7 @@ describe('agent model resolution', () => {
514
513
  const threadText = await discord.thread(thread.id).text();
515
514
  expect(threadText).toContain('first message in thread');
516
515
  expect(threadText).toContain('Reply with exactly: reply-context-check');
517
- expect(threadText).toContain('⬥ ok');
516
+ expect(threadText).toContain('ok');
518
517
  }, 15_000);
519
518
  test('new thread uses channel model when channel model preference is set', async () => {
520
519
  // Clear channel agent so model resolution falls through to channel model
@@ -544,15 +543,15 @@ describe('agent model resolution', () => {
544
543
  const messages = await discord.thread(thread.id).getMessages();
545
544
  const footerMessage = messages.find((message) => {
546
545
  return (message.author.id === discord.botUserId &&
547
- message.content.startsWith('*'));
546
+ isFooterMessage({ message, botUserId: discord.botUserId }));
548
547
  });
549
548
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
550
549
  "--- from: user (agent-model-tester)
551
550
  Reply with exactly: channel-model-check
552
551
  --- from: assistant (TestBot)
553
- *using deterministic-provider/channel-model-v2*
554
- ⬥ ok
555
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
552
+ > *using deterministic-provider/channel-model-v2*
553
+ > ok
554
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>"
556
555
  `);
557
556
  expect(footerMessage).toBeDefined();
558
557
  if (!footerMessage) {
@@ -565,59 +564,41 @@ describe('agent model resolution', () => {
565
564
  expect(footerMessage.content).toContain(CHANNEL_MODEL);
566
565
  expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
567
566
  }, 15_000);
568
- test('channel model with variant preference completes without error', async () => {
569
- // Clear channel agent so model resolution falls through to channel model
567
+ test('unsupported channel model variant falls back to the selected model', async () => {
570
568
  const db = await getDb();
571
569
  await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
572
- // Set channel model with a variant (thinking level)
573
- // The deterministic provider doesn't support thinking, so the variant
574
- // is resolved but silently dropped (no matching thinking values).
575
- // This test verifies the variant cascade code path runs without crashing
576
- // and the correct model still appears in the footer.
577
570
  await setChannelModel({
578
571
  channelId: TEXT_CHANNEL_ID,
579
572
  modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
580
573
  variant: 'high',
581
574
  });
575
+ const prompt = 'Reply with exactly: variant-check';
582
576
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
583
- content: 'Reply with exactly: variant-check',
577
+ content: prompt,
584
578
  });
585
579
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
586
580
  timeout: 4_000,
587
- predicate: (t) => {
588
- return t.name === 'Reply with exactly: variant-check';
589
- },
581
+ predicate: (candidate) => candidate.name === prompt,
590
582
  });
591
- await waitForBotMessageContaining({
583
+ const messages = await waitForFooterMessage({
592
584
  discord,
593
585
  threadId: thread.id,
594
- userId: TEST_USER_ID,
595
- text: '*project',
596
586
  timeout: 4_000,
597
587
  });
598
- const messages = await discord.thread(thread.id).getMessages();
599
- const footerMessage = messages.find((message) => {
600
- return (message.author.id === discord.botUserId &&
601
- message.content.startsWith('*'));
588
+ const footer = messages.find((message) => {
589
+ return message.author.id === discord.botUserId
590
+ && isFooterMessage({ message, botUserId: discord.botUserId });
602
591
  });
603
592
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
604
593
  "--- from: user (agent-model-tester)
605
594
  Reply with exactly: variant-check
606
595
  --- from: assistant (TestBot)
607
- *using deterministic-provider/channel-model-v2*
608
- ⬥ ok
609
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
596
+ > *using deterministic-provider/channel-model-v2*
597
+ > ok
598
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>"
610
599
  `);
611
- expect(footerMessage).toBeDefined();
612
- if (!footerMessage) {
613
- throw new Error(`Expected footer message but none found. Bot messages: ${messages
614
- .filter((m) => m.author.id === discord.botUserId)
615
- .map((m) => m.content.slice(0, 150))
616
- .join(' | ')}`);
617
- }
618
- // Footer should still contain the channel model (variant doesn't crash)
619
- expect(footerMessage.content).toContain(CHANNEL_MODEL);
620
- expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
600
+ expect(footer?.content).toContain(CHANNEL_MODEL);
601
+ expect(footer?.content).not.toContain(DEFAULT_MODEL);
621
602
  }, 15_000);
622
603
  test('/btw fork keeps source session model when channel model differs', async () => {
623
604
  const db = await getDb();
@@ -680,8 +661,8 @@ describe('agent model resolution', () => {
680
661
  "--- from: assistant (TestBot)
681
662
  Reusing context from <#SOURCE_THREAD> to answer prompt...
682
663
  Reply with exactly: btw-model-check
683
- ⬥ ok
684
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2*"
664
+ > ok
665
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2* <@200000000000000920>"
685
666
  `);
686
667
  expect(forkedSessionModel).toMatchInlineSnapshot(`
687
668
  {
@@ -714,7 +695,7 @@ describe('agent model resolution', () => {
714
695
  });
715
696
  const firstMessages = await discord.thread(thread.id).getMessages();
716
697
  const firstFooter = firstMessages.find((m) => {
717
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
698
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
718
699
  });
719
700
  expect(firstFooter).toBeDefined();
720
701
  // Verify the first run used test-agent's model
@@ -744,20 +725,20 @@ describe('agent model resolution', () => {
744
725
  "--- from: user (agent-model-tester)
745
726
  Reply with exactly: first-thread-msg
746
727
  --- from: assistant (TestBot)
747
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
748
- ⬥ ok
749
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
728
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
729
+ > ok
730
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
750
731
  --- from: user (agent-model-tester)
751
732
  Reply with exactly: second-thread-msg
752
733
  --- from: assistant (TestBot)
753
- ⬥ ok
754
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
734
+ > ok
735
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>"
755
736
  `);
756
737
  const secondMessages = await discord.thread(thread.id).getMessages();
757
738
  const secondFooter = [...secondMessages]
758
739
  .reverse()
759
740
  .find((m) => {
760
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
741
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
761
742
  });
762
743
  expect(secondFooter).toBeDefined();
763
744
  // The existing thread should still use test-agent's model (AGENT_MODEL),
@@ -765,23 +746,25 @@ describe('agent model resolution', () => {
765
746
  expect(secondFooter.content).toContain(AGENT_MODEL);
766
747
  expect(secondFooter.content).not.toContain(PLAN_AGENT_MODEL);
767
748
  }, 20_000);
768
- test('thread created with no agent keeps default model after channel agent is set', async () => {
769
- // Clear any channel agent — thread starts with default (no agent)
770
- const db = await getDb();
771
- await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
772
- // Also clear channel model so we get the pure default
773
- await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
774
- // 1. Send a message to create a thread (no channel agent set)
775
- await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
776
- content: 'Reply with exactly: default-thread-msg',
749
+ test('/plan-agent with prompt starts a session with the plan agent', async () => {
750
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
751
+ const prompt = 'Reply with exactly: inline-plan-agent-msg';
752
+ const { id: interactionId } = await discord
753
+ .channel(TEXT_CHANNEL_ID)
754
+ .user(TEST_USER_ID)
755
+ .runSlashCommand({
756
+ name: 'plan-agent',
757
+ options: [{ name: 'prompt', type: 3, value: prompt }],
777
758
  });
759
+ await discord
760
+ .channel(TEXT_CHANNEL_ID)
761
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
778
762
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
779
763
  timeout: 4_000,
780
764
  predicate: (t) => {
781
- return t.name === 'Reply with exactly: default-thread-msg';
765
+ return t.name === prompt;
782
766
  },
783
767
  });
784
- // Wait for footer — should show the default model
785
768
  await waitForFooterMessage({
786
769
  discord,
787
770
  threadId: thread.id,
@@ -789,79 +772,36 @@ describe('agent model resolution', () => {
789
772
  afterMessageIncludes: 'ok',
790
773
  afterAuthorId: discord.botUserId,
791
774
  });
792
- const firstMessages = await discord.thread(thread.id).getMessages();
793
- const firstFooter = firstMessages.find((m) => {
794
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
795
- });
796
- expect(firstFooter).toBeDefined();
797
- // First run uses the default model (no agent set)
798
- expect(firstFooter.content).toContain(DEFAULT_MODEL);
799
- expect(firstFooter.content).not.toContain(AGENT_MODEL);
800
- // 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
801
- const { id: interactionId } = await discord
802
- .channel(TEXT_CHANNEL_ID)
803
- .user(TEST_USER_ID)
804
- .runSlashCommand({ name: 'test-agent-agent' });
805
- await discord
806
- .channel(TEXT_CHANNEL_ID)
807
- .waitForInteractionAck({ interactionId, timeout: 4_000 });
808
- // 3. Send a second message in the EXISTING thread
809
- await discord
810
- .thread(thread.id)
811
- .user(TEST_USER_ID)
812
- .sendMessage({
813
- content: 'Reply with exactly: default-second-msg',
814
- });
815
- await waitForFooterMessage({
816
- discord,
817
- threadId: thread.id,
818
- timeout: 4_000,
819
- afterMessageIncludes: 'default-second-msg',
820
- afterAuthorId: TEST_USER_ID,
821
- });
775
+ const sessionId = await getThreadSession(thread.id);
776
+ expect(sessionId).toBeDefined();
777
+ expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
778
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
822
779
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
823
- "--- from: user (agent-model-tester)
824
- Reply with exactly: default-thread-msg
825
- --- from: assistant (TestBot)
826
- *using deterministic-provider/deterministic-v2*
827
- ⬥ ok
828
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
829
- --- from: user (agent-model-tester)
830
- Reply with exactly: default-second-msg
831
- --- from: assistant (TestBot)
832
- ⬥ ok
833
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
780
+ "--- from: assistant (TestBot)
781
+ » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
782
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
783
+ > ok
784
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
834
785
  `);
835
- const secondMessages = await discord.thread(thread.id).getMessages();
836
- const secondFooter = [...secondMessages]
837
- .reverse()
838
- .find((m) => {
839
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
840
- });
841
- expect(secondFooter).toBeDefined();
842
- // The existing thread should still use the DEFAULT model,
843
- // NOT the test-agent's model (AGENT_MODEL)
844
- expect(secondFooter.content).toContain(DEFAULT_MODEL);
845
- expect(secondFooter.content).not.toContain(AGENT_MODEL);
846
786
  }, 20_000);
847
- test('/plan-agent with prompt starts a session with the plan agent', async () => {
848
- await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
849
- const prompt = 'Reply with exactly: inline-plan-agent-msg';
787
+ test('/plan-agent with prompt persists a supported thinking variant', async () => {
788
+ const prompt = 'Reply with exactly: inline-plan-agent-variant-msg';
850
789
  const { id: interactionId } = await discord
851
790
  .channel(TEXT_CHANNEL_ID)
852
791
  .user(TEST_USER_ID)
853
792
  .runSlashCommand({
854
793
  name: 'plan-agent',
855
- options: [{ name: 'prompt', type: 3, value: prompt }],
794
+ options: [
795
+ { name: 'prompt', type: 3, value: prompt },
796
+ { name: 'variant', type: 3, value: 'high' },
797
+ ],
856
798
  });
857
799
  await discord
858
800
  .channel(TEXT_CHANNEL_ID)
859
801
  .waitForInteractionAck({ interactionId, timeout: 4_000 });
860
802
  const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
861
803
  timeout: 4_000,
862
- predicate: (t) => {
863
- return t.name === prompt;
864
- },
804
+ predicate: (t) => t.name === prompt,
865
805
  });
866
806
  await waitForFooterMessage({
867
807
  discord,
@@ -872,16 +812,62 @@ describe('agent model resolution', () => {
872
812
  });
873
813
  const sessionId = await getThreadSession(thread.id);
874
814
  expect(sessionId).toBeDefined();
875
- expect(sessionId ? await getSessionAgent(sessionId) : undefined).toBe('plan');
876
- expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('test-agent');
877
815
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
878
816
  "--- from: assistant (TestBot)
879
- » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-msg
880
- *using deterministic-provider/plan-model-v2 ⋅ plan*
881
- ⬥ ok
882
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
817
+ » **agent-model-tester** (plan): Reply with exactly: inline-plan-agent-variant-msg
818
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
819
+ > ok
820
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
821
+ `);
822
+ expect(sessionId ? await getSessionModel(sessionId) : undefined).toMatchInlineSnapshot(`
823
+ {
824
+ "modelId": "deterministic-provider/plan-model-v2",
825
+ "variant": "high",
826
+ }
883
827
  `);
884
828
  }, 20_000);
829
+ test('/plan-agent variant persists on the channel without a prompt', async () => {
830
+ const db = await getDb();
831
+ await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
832
+ const { id: interactionId } = await discord
833
+ .channel(TEXT_CHANNEL_ID)
834
+ .user(TEST_USER_ID)
835
+ .runSlashCommand({
836
+ name: 'plan-agent',
837
+ options: [{ name: 'variant', type: 3, value: 'max' }],
838
+ });
839
+ await discord
840
+ .channel(TEXT_CHANNEL_ID)
841
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
842
+ const start = Date.now();
843
+ let confirmation = '';
844
+ while (Date.now() - start < 4_000) {
845
+ const messages = await discord.channel(TEXT_CHANNEL_ID).getMessages();
846
+ const match = [...messages].reverse().find((message) => {
847
+ return (message.author.id === discord.botUserId &&
848
+ message.content.includes('Variant: **max**'));
849
+ });
850
+ if (match) {
851
+ confirmation = match.content;
852
+ break;
853
+ }
854
+ await new Promise((resolve) => setTimeout(resolve, 100));
855
+ }
856
+ expect(confirmation).toMatchInlineSnapshot(`
857
+ "Switched to **plan** agent for this channel (was **test-agent**)
858
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
859
+ Variant: **max**
860
+ All new sessions will use this agent."
861
+ `);
862
+ expect(await getChannelAgent(TEXT_CHANNEL_ID)).toBe('plan');
863
+ expect(await getChannelModel(TEXT_CHANNEL_ID)).toMatchInlineSnapshot(`
864
+ {
865
+ "modelId": "deterministic-provider/plan-model-v2",
866
+ "variant": "max",
867
+ }
868
+ `);
869
+ await db.delete(schema.channel_models).where(orm.eq(schema.channel_models.channel_id, TEXT_CHANNEL_ID));
870
+ }, 20_000);
885
871
  test('/plan-agent with prompt in an existing thread changes the session agent', async () => {
886
872
  await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
887
873
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
@@ -922,12 +908,12 @@ describe('agent model resolution', () => {
922
908
  "--- from: user (agent-model-tester)
923
909
  Reply with exactly: inline-existing-first-msg
924
910
  --- from: assistant (TestBot)
925
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
926
- ⬥ ok
927
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
911
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
912
+ > ok
913
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
928
914
  » **agent-model-tester** (plan): Reply with exactly: inline-existing-plan-msg
929
- ⬥ ok
930
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
915
+ > ok
916
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
931
917
  `);
932
918
  }, 20_000);
933
919
  test('/plan-agent inside a thread switches the model for that thread', async () => {
@@ -951,7 +937,7 @@ describe('agent model resolution', () => {
951
937
  afterAuthorId: discord.botUserId,
952
938
  });
953
939
  const firstFooter = (await discord.thread(thread.id).getMessages()).find((m) => {
954
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
940
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
955
941
  });
956
942
  expect(firstFooter).toBeDefined();
957
943
  expect(firstFooter.content).toContain(AGENT_MODEL);
@@ -976,28 +962,128 @@ describe('agent model resolution', () => {
976
962
  "--- from: user (agent-model-tester)
977
963
  Reply with exactly: switch-in-thread-msg
978
964
  --- from: assistant (TestBot)
979
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
980
- ⬥ ok
981
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
965
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
966
+ > ok
967
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
982
968
  Switched to **plan** agent for this session (was **test-agent**)
983
969
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
984
970
  The agent will change on the next message.
985
971
  --- from: user (agent-model-tester)
986
972
  Reply with exactly: after-switch-msg
987
973
  --- from: assistant (TestBot)
988
- ⬥ ok
989
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
974
+ > ok
975
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
990
976
  `);
991
977
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
992
978
  .reverse()
993
979
  .find((m) => {
994
- return (m.author.id === discord.botUserId && m.content.startsWith('*'));
980
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
995
981
  });
996
982
  expect(secondFooter).toBeDefined();
997
983
  // After /plan-agent in the thread, model should switch to plan's model
998
984
  expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
999
985
  expect(secondFooter.content).not.toContain(AGENT_MODEL);
1000
986
  }, 20_000);
987
+ test('/plan-agent then an immediate follow-up uses the new agent', async () => {
988
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
989
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
990
+ content: 'Reply with exactly: race-switch-first-msg',
991
+ });
992
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
993
+ timeout: 4_000,
994
+ predicate: (t) => {
995
+ return t.name === 'Reply with exactly: race-switch-first-msg';
996
+ },
997
+ });
998
+ await waitForFooterMessage({
999
+ discord,
1000
+ threadId: thread.id,
1001
+ timeout: 4_000,
1002
+ afterMessageIncludes: 'ok',
1003
+ afterAuthorId: discord.botUserId,
1004
+ });
1005
+ const th = discord.thread(thread.id);
1006
+ const { id: interactionId } = await th
1007
+ .user(TEST_USER_ID)
1008
+ .runSlashCommand({ name: 'plan-agent' });
1009
+ await th.user(TEST_USER_ID).sendMessage({
1010
+ content: 'Reply with exactly: race-switch-follow-up',
1011
+ });
1012
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
1013
+ await waitForFooterMessage({
1014
+ discord,
1015
+ threadId: thread.id,
1016
+ timeout: 4_000,
1017
+ afterMessageIncludes: 'race-switch-follow-up',
1018
+ afterAuthorId: TEST_USER_ID,
1019
+ });
1020
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
1021
+ "--- from: user (agent-model-tester)
1022
+ Reply with exactly: race-switch-first-msg
1023
+ --- from: assistant (TestBot)
1024
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1025
+ > ok
1026
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
1027
+ --- from: user (agent-model-tester)
1028
+ Reply with exactly: race-switch-follow-up
1029
+ --- from: assistant (TestBot)
1030
+ Switched to **plan** agent for this session (was **test-agent**)
1031
+ Model: *deterministic-provider/plan-model-v2* (agent "plan")
1032
+ The agent will change on the next message.
1033
+ > ok
1034
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
1035
+ `);
1036
+ const secondFooter = [...(await discord.thread(thread.id).getMessages())]
1037
+ .reverse()
1038
+ .find((m) => {
1039
+ return (isFooterMessage({ message: m, botUserId: discord.botUserId }));
1040
+ });
1041
+ expect(secondFooter).toBeDefined();
1042
+ expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
1043
+ expect(secondFooter.content).not.toContain(AGENT_MODEL);
1044
+ }, 20_000);
1045
+ test('/plan-agent in a channel then an immediate message uses the new agent', async () => {
1046
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
1047
+ const { id: interactionId } = await discord
1048
+ .channel(TEXT_CHANNEL_ID)
1049
+ .user(TEST_USER_ID)
1050
+ .runSlashCommand({ name: 'plan-agent' });
1051
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1052
+ content: 'Reply with exactly: race-channel-follow-up',
1053
+ });
1054
+ await discord
1055
+ .channel(TEXT_CHANNEL_ID)
1056
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
1057
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1058
+ timeout: 4_000,
1059
+ predicate: (t) => {
1060
+ return t.name === 'Reply with exactly: race-channel-follow-up';
1061
+ },
1062
+ });
1063
+ await waitForFooterMessage({
1064
+ discord,
1065
+ threadId: thread.id,
1066
+ timeout: 4_000,
1067
+ afterMessageIncludes: 'ok',
1068
+ afterAuthorId: discord.botUserId,
1069
+ });
1070
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
1071
+ "--- from: user (agent-model-tester)
1072
+ Reply with exactly: race-channel-follow-up
1073
+ --- from: assistant (TestBot)
1074
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
1075
+ > ok
1076
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>"
1077
+ `);
1078
+ const footer = [...(await discord.thread(thread.id).getMessages())]
1079
+ .reverse()
1080
+ .find((m) => {
1081
+ return isFooterMessage({ message: m, botUserId: discord.botUserId });
1082
+ });
1083
+ expect(footer).toBeDefined();
1084
+ expect(footer.content).toContain(PLAN_AGENT_MODEL);
1085
+ expect(footer.content).not.toContain(AGENT_MODEL);
1086
+ }, 20_000);
1001
1087
  test('/plan-agent on the same agent refreshes a stale session model', async () => {
1002
1088
  await setChannelAgent(TEXT_CHANNEL_ID, 'plan');
1003
1089
  await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
@@ -1033,9 +1119,9 @@ describe('agent model resolution', () => {
1033
1119
  "--- from: user (agent-model-tester)
1034
1120
  Reply with exactly: refresh-agent-model-msg
1035
1121
  --- from: assistant (TestBot)
1036
- *using deterministic-provider/plan-model-v2 ⋅ plan*
1037
- ⬥ ok
1038
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***
1122
+ > *using deterministic-provider/plan-model-v2 ⋅ plan*
1123
+ > ok
1124
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ plan-model-v2 ⋅ **plan*** <@200000000000000920>
1039
1125
  Using **plan** agent for this session
1040
1126
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1041
1127
  The agent will change on the next message."
@@ -1075,9 +1161,9 @@ describe('agent model resolution', () => {
1075
1161
  "--- from: user (agent-model-tester)
1076
1162
  Reply with exactly: channel-vs-agent-msg
1077
1163
  --- from: assistant (TestBot)
1078
- *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1079
- ⬥ ok
1080
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
1164
+ > *using deterministic-provider/agent-model-v2 ⋅ test-agent*
1165
+ > ok
1166
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ agent-model-v2 ⋅ **test-agent*** <@200000000000000920>
1081
1167
  Switched to **plan** agent for this session (was **test-agent**)
1082
1168
  Model: *deterministic-provider/plan-model-v2* (agent "plan")
1083
1169
  The agent will change on the next message."
@@ -1117,9 +1203,9 @@ describe('agent model resolution', () => {
1117
1203
  "--- from: user (agent-model-tester)
1118
1204
  Reply with exactly: plain-agent-override-msg
1119
1205
  --- from: assistant (TestBot)
1120
- *using deterministic-provider/channel-model-v2*
1121
- ⬥ ok
1122
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*
1206
+ > *using deterministic-provider/channel-model-v2*
1207
+ > ok
1208
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ channel-model-v2* <@200000000000000920>
1123
1209
  Switched to **plain** agent for this session
1124
1210
  Model: *deterministic-provider/channel-model-v2* (channel override)
1125
1211
  This model comes from a channel override. Use /model and press Clear override if you want this agent's model.
@@ -1,4 +1,12 @@
1
- // Helpers for extracting and normalizing Anthropic OAuth account identity.
1
+ // LEGACY rotation, superseded by @subrouter/opencode. See oauth-rotation-shared.ts.
2
+ // Helpers for Anthropic OAuth account and Claude Code client identity.
3
+ const CLAUDE_CODE_USER_AGENT = 'claude-cli/2.1.257 (external, cli)';
4
+ export function applyClaudeCodeRequestIdentity({ headers, accessToken, }) {
5
+ headers.set('authorization', `Bearer ${accessToken}`);
6
+ headers.set('user-agent', process.env.OPENCODE_ANTHROPIC_USER_AGENT || CLAUDE_CODE_USER_AGENT);
7
+ headers.set('x-app', 'cli');
8
+ return headers;
9
+ }
2
10
  const identityHintKeys = new Set(['user', 'profile', 'account', 'viewer']);
3
11
  const idKeys = ['user_id', 'userId', 'account_id', 'accountId', 'id', 'sub'];
4
12
  export function normalizeAnthropicAccountIdentity(identity) {