@brianli/kimaki 0.4.72-brianli.1

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 (328) hide show
  1. package/bin.js +2 -0
  2. package/dist/ai-tool-to-genai.js +233 -0
  3. package/dist/ai-tool-to-genai.test.js +267 -0
  4. package/dist/ai-tool.js +6 -0
  5. package/dist/bin.js +87 -0
  6. package/dist/bot-token.js +121 -0
  7. package/dist/bot-token.test.js +134 -0
  8. package/dist/channel-management.js +101 -0
  9. package/dist/cli-parsing.test.js +89 -0
  10. package/dist/cli.js +2529 -0
  11. package/dist/commands/abort.js +82 -0
  12. package/dist/commands/action-buttons.js +257 -0
  13. package/dist/commands/add-project.js +114 -0
  14. package/dist/commands/agent.js +291 -0
  15. package/dist/commands/ask-question.js +223 -0
  16. package/dist/commands/compact.js +120 -0
  17. package/dist/commands/context-usage.js +140 -0
  18. package/dist/commands/create-new-project.js +118 -0
  19. package/dist/commands/diff.js +128 -0
  20. package/dist/commands/file-upload.js +275 -0
  21. package/dist/commands/fork.js +217 -0
  22. package/dist/commands/gemini-apikey.js +70 -0
  23. package/dist/commands/login.js +490 -0
  24. package/dist/commands/mention-mode.js +51 -0
  25. package/dist/commands/merge-worktree.js +124 -0
  26. package/dist/commands/model.js +694 -0
  27. package/dist/commands/permissions.js +163 -0
  28. package/dist/commands/queue.js +217 -0
  29. package/dist/commands/remove-project.js +115 -0
  30. package/dist/commands/restart-opencode-server.js +116 -0
  31. package/dist/commands/resume.js +159 -0
  32. package/dist/commands/run-command.js +79 -0
  33. package/dist/commands/session-id.js +78 -0
  34. package/dist/commands/session.js +192 -0
  35. package/dist/commands/share.js +80 -0
  36. package/dist/commands/types.js +2 -0
  37. package/dist/commands/undo-redo.js +159 -0
  38. package/dist/commands/unset-model.js +152 -0
  39. package/dist/commands/upgrade.js +42 -0
  40. package/dist/commands/user-command.js +148 -0
  41. package/dist/commands/verbosity.js +60 -0
  42. package/dist/commands/worktree-settings.js +50 -0
  43. package/dist/commands/worktree.js +299 -0
  44. package/dist/condense-memory.js +33 -0
  45. package/dist/config.js +110 -0
  46. package/dist/database.js +1050 -0
  47. package/dist/db.js +159 -0
  48. package/dist/db.test.js +49 -0
  49. package/dist/discord-api.js +28 -0
  50. package/dist/discord-auth.js +231 -0
  51. package/dist/discord-auth.test.js +80 -0
  52. package/dist/discord-bot.js +997 -0
  53. package/dist/discord-utils.js +560 -0
  54. package/dist/discord-utils.test.js +115 -0
  55. package/dist/errors.js +167 -0
  56. package/dist/escape-backticks.test.js +429 -0
  57. package/dist/format-tables.js +122 -0
  58. package/dist/format-tables.test.js +199 -0
  59. package/dist/forum-sync/config.js +79 -0
  60. package/dist/forum-sync/discord-operations.js +154 -0
  61. package/dist/forum-sync/index.js +5 -0
  62. package/dist/forum-sync/markdown.js +117 -0
  63. package/dist/forum-sync/sync-to-discord.js +417 -0
  64. package/dist/forum-sync/sync-to-files.js +190 -0
  65. package/dist/forum-sync/types.js +53 -0
  66. package/dist/forum-sync/watchers.js +307 -0
  67. package/dist/gateway-consumer.js +232 -0
  68. package/dist/gateway-consumer.test.js +18 -0
  69. package/dist/genai-worker-wrapper.js +111 -0
  70. package/dist/genai-worker.js +311 -0
  71. package/dist/genai.js +232 -0
  72. package/dist/generated/browser.js +17 -0
  73. package/dist/generated/client.js +35 -0
  74. package/dist/generated/commonInputTypes.js +10 -0
  75. package/dist/generated/enums.js +30 -0
  76. package/dist/generated/internal/class.js +41 -0
  77. package/dist/generated/internal/prismaNamespace.js +239 -0
  78. package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
  79. package/dist/generated/models/bot_api_keys.js +1 -0
  80. package/dist/generated/models/bot_tokens.js +1 -0
  81. package/dist/generated/models/channel_agents.js +1 -0
  82. package/dist/generated/models/channel_directories.js +1 -0
  83. package/dist/generated/models/channel_mention_mode.js +1 -0
  84. package/dist/generated/models/channel_models.js +1 -0
  85. package/dist/generated/models/channel_verbosity.js +1 -0
  86. package/dist/generated/models/channel_worktrees.js +1 -0
  87. package/dist/generated/models/forum_sync_configs.js +1 -0
  88. package/dist/generated/models/global_models.js +1 -0
  89. package/dist/generated/models/ipc_requests.js +1 -0
  90. package/dist/generated/models/part_messages.js +1 -0
  91. package/dist/generated/models/scheduled_tasks.js +1 -0
  92. package/dist/generated/models/session_agents.js +1 -0
  93. package/dist/generated/models/session_models.js +1 -0
  94. package/dist/generated/models/session_start_sources.js +1 -0
  95. package/dist/generated/models/thread_sessions.js +1 -0
  96. package/dist/generated/models/thread_worktrees.js +1 -0
  97. package/dist/generated/models.js +1 -0
  98. package/dist/heap-monitor.js +95 -0
  99. package/dist/hrana-server.js +416 -0
  100. package/dist/hrana-server.test.js +368 -0
  101. package/dist/image-utils.js +112 -0
  102. package/dist/interaction-handler.js +327 -0
  103. package/dist/ipc-polling.js +251 -0
  104. package/dist/kimaki-digital-twin.e2e.test.js +165 -0
  105. package/dist/limit-heading-depth.js +25 -0
  106. package/dist/limit-heading-depth.test.js +105 -0
  107. package/dist/logger.js +160 -0
  108. package/dist/markdown.js +342 -0
  109. package/dist/markdown.test.js +253 -0
  110. package/dist/message-formatting.js +433 -0
  111. package/dist/message-formatting.test.js +73 -0
  112. package/dist/openai-realtime.js +228 -0
  113. package/dist/opencode-plugin-loading.e2e.test.js +91 -0
  114. package/dist/opencode-plugin.js +536 -0
  115. package/dist/opencode-plugin.test.js +98 -0
  116. package/dist/opencode.js +409 -0
  117. package/dist/privacy-sanitizer.js +105 -0
  118. package/dist/runtime-mode.js +51 -0
  119. package/dist/runtime-mode.test.js +115 -0
  120. package/dist/sentry.js +127 -0
  121. package/dist/session-handler/state.js +151 -0
  122. package/dist/session-handler.js +1874 -0
  123. package/dist/session-search.js +100 -0
  124. package/dist/session-search.test.js +40 -0
  125. package/dist/startup-service.js +153 -0
  126. package/dist/system-message.js +499 -0
  127. package/dist/task-runner.js +282 -0
  128. package/dist/task-schedule.js +191 -0
  129. package/dist/task-schedule.test.js +71 -0
  130. package/dist/thinking-utils.js +35 -0
  131. package/dist/thread-message-queue.e2e.test.js +781 -0
  132. package/dist/tools.js +359 -0
  133. package/dist/unnest-code-blocks.js +136 -0
  134. package/dist/unnest-code-blocks.test.js +641 -0
  135. package/dist/upgrade.js +114 -0
  136. package/dist/utils.js +109 -0
  137. package/dist/voice-handler.js +606 -0
  138. package/dist/voice.js +304 -0
  139. package/dist/voice.test.js +187 -0
  140. package/dist/wait-session.js +94 -0
  141. package/dist/worker-types.js +4 -0
  142. package/dist/worktree-utils.js +727 -0
  143. package/dist/xml.js +92 -0
  144. package/dist/xml.test.js +32 -0
  145. package/package.json +82 -0
  146. package/schema.prisma +246 -0
  147. package/skills/batch/SKILL.md +87 -0
  148. package/skills/critique/SKILL.md +129 -0
  149. package/skills/errore/SKILL.md +589 -0
  150. package/skills/goke/.prettierrc +5 -0
  151. package/skills/goke/CHANGELOG.md +40 -0
  152. package/skills/goke/LICENSE +21 -0
  153. package/skills/goke/README.md +666 -0
  154. package/skills/goke/SKILL.md +458 -0
  155. package/skills/goke/package.json +43 -0
  156. package/skills/goke/src/__test__/coerce.test.ts +411 -0
  157. package/skills/goke/src/__test__/index.test.ts +1798 -0
  158. package/skills/goke/src/__test__/types.test-d.ts +111 -0
  159. package/skills/goke/src/coerce.ts +547 -0
  160. package/skills/goke/src/goke.ts +1362 -0
  161. package/skills/goke/src/index.ts +16 -0
  162. package/skills/goke/src/mri.ts +164 -0
  163. package/skills/goke/tsconfig.json +15 -0
  164. package/skills/jitter/EDITOR.md +219 -0
  165. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  166. package/skills/jitter/SKILL.md +158 -0
  167. package/skills/jitter/jitter-clipboard.json +1042 -0
  168. package/skills/jitter/package.json +14 -0
  169. package/skills/jitter/tsconfig.json +15 -0
  170. package/skills/jitter/utils/actions.ts +212 -0
  171. package/skills/jitter/utils/export.ts +114 -0
  172. package/skills/jitter/utils/index.ts +141 -0
  173. package/skills/jitter/utils/snapshot.ts +154 -0
  174. package/skills/jitter/utils/traverse.ts +246 -0
  175. package/skills/jitter/utils/types.ts +279 -0
  176. package/skills/jitter/utils/wait.ts +133 -0
  177. package/skills/playwriter/SKILL.md +31 -0
  178. package/skills/security-review/SKILL.md +208 -0
  179. package/skills/simplify/SKILL.md +58 -0
  180. package/skills/termcast/SKILL.md +945 -0
  181. package/skills/tuistory/SKILL.md +250 -0
  182. package/skills/zustand-centralized-state/SKILL.md +582 -0
  183. package/src/__snapshots__/compact-session-context-no-system.md +35 -0
  184. package/src/__snapshots__/compact-session-context.md +41 -0
  185. package/src/__snapshots__/first-session-no-info.md +17 -0
  186. package/src/__snapshots__/first-session-with-info.md +23 -0
  187. package/src/__snapshots__/session-1.md +17 -0
  188. package/src/__snapshots__/session-2.md +5871 -0
  189. package/src/__snapshots__/session-3.md +17 -0
  190. package/src/__snapshots__/session-with-tools.md +5871 -0
  191. package/src/ai-tool-to-genai.test.ts +296 -0
  192. package/src/ai-tool-to-genai.ts +282 -0
  193. package/src/ai-tool.ts +39 -0
  194. package/src/bin.ts +108 -0
  195. package/src/bot-token.test.ts +171 -0
  196. package/src/bot-token.ts +159 -0
  197. package/src/channel-management.ts +172 -0
  198. package/src/cli-parsing.test.ts +132 -0
  199. package/src/cli.ts +3605 -0
  200. package/src/commands/abort.ts +112 -0
  201. package/src/commands/action-buttons.ts +376 -0
  202. package/src/commands/add-project.ts +152 -0
  203. package/src/commands/agent.ts +404 -0
  204. package/src/commands/ask-question.ts +330 -0
  205. package/src/commands/compact.ts +157 -0
  206. package/src/commands/context-usage.ts +199 -0
  207. package/src/commands/create-new-project.ts +179 -0
  208. package/src/commands/diff.ts +165 -0
  209. package/src/commands/file-upload.ts +389 -0
  210. package/src/commands/fork.ts +320 -0
  211. package/src/commands/gemini-apikey.ts +104 -0
  212. package/src/commands/login.ts +634 -0
  213. package/src/commands/mention-mode.ts +77 -0
  214. package/src/commands/merge-worktree.ts +177 -0
  215. package/src/commands/model.ts +961 -0
  216. package/src/commands/permissions.ts +261 -0
  217. package/src/commands/queue.ts +296 -0
  218. package/src/commands/remove-project.ts +155 -0
  219. package/src/commands/restart-opencode-server.ts +162 -0
  220. package/src/commands/resume.ts +242 -0
  221. package/src/commands/run-command.ts +123 -0
  222. package/src/commands/session-id.ts +109 -0
  223. package/src/commands/session.ts +250 -0
  224. package/src/commands/share.ts +106 -0
  225. package/src/commands/types.ts +25 -0
  226. package/src/commands/undo-redo.ts +221 -0
  227. package/src/commands/unset-model.ts +189 -0
  228. package/src/commands/upgrade.ts +52 -0
  229. package/src/commands/user-command.ts +193 -0
  230. package/src/commands/verbosity.ts +88 -0
  231. package/src/commands/worktree-settings.ts +79 -0
  232. package/src/commands/worktree.ts +431 -0
  233. package/src/condense-memory.ts +36 -0
  234. package/src/config.ts +148 -0
  235. package/src/database.ts +1530 -0
  236. package/src/db.test.ts +60 -0
  237. package/src/db.ts +190 -0
  238. package/src/discord-api.ts +35 -0
  239. package/src/discord-bot.ts +1316 -0
  240. package/src/discord-utils.test.ts +132 -0
  241. package/src/discord-utils.ts +767 -0
  242. package/src/errors.ts +213 -0
  243. package/src/escape-backticks.test.ts +469 -0
  244. package/src/format-tables.test.ts +223 -0
  245. package/src/format-tables.ts +145 -0
  246. package/src/forum-sync/config.ts +92 -0
  247. package/src/forum-sync/discord-operations.ts +241 -0
  248. package/src/forum-sync/index.ts +9 -0
  249. package/src/forum-sync/markdown.ts +176 -0
  250. package/src/forum-sync/sync-to-discord.ts +595 -0
  251. package/src/forum-sync/sync-to-files.ts +294 -0
  252. package/src/forum-sync/types.ts +175 -0
  253. package/src/forum-sync/watchers.ts +454 -0
  254. package/src/genai-worker-wrapper.ts +164 -0
  255. package/src/genai-worker.ts +386 -0
  256. package/src/genai.ts +321 -0
  257. package/src/generated/browser.ts +109 -0
  258. package/src/generated/client.ts +131 -0
  259. package/src/generated/commonInputTypes.ts +512 -0
  260. package/src/generated/enums.ts +46 -0
  261. package/src/generated/internal/class.ts +362 -0
  262. package/src/generated/internal/prismaNamespace.ts +2251 -0
  263. package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
  264. package/src/generated/models/bot_api_keys.ts +1288 -0
  265. package/src/generated/models/bot_tokens.ts +1577 -0
  266. package/src/generated/models/channel_agents.ts +1256 -0
  267. package/src/generated/models/channel_directories.ts +2104 -0
  268. package/src/generated/models/channel_mention_mode.ts +1300 -0
  269. package/src/generated/models/channel_models.ts +1288 -0
  270. package/src/generated/models/channel_verbosity.ts +1224 -0
  271. package/src/generated/models/channel_worktrees.ts +1308 -0
  272. package/src/generated/models/forum_sync_configs.ts +1452 -0
  273. package/src/generated/models/global_models.ts +1288 -0
  274. package/src/generated/models/ipc_requests.ts +1485 -0
  275. package/src/generated/models/part_messages.ts +1302 -0
  276. package/src/generated/models/scheduled_tasks.ts +2320 -0
  277. package/src/generated/models/session_agents.ts +1086 -0
  278. package/src/generated/models/session_models.ts +1114 -0
  279. package/src/generated/models/session_start_sources.ts +1408 -0
  280. package/src/generated/models/thread_sessions.ts +1599 -0
  281. package/src/generated/models/thread_worktrees.ts +1352 -0
  282. package/src/generated/models.ts +29 -0
  283. package/src/heap-monitor.ts +121 -0
  284. package/src/hrana-server.test.ts +428 -0
  285. package/src/hrana-server.ts +547 -0
  286. package/src/image-utils.ts +149 -0
  287. package/src/interaction-handler.ts +461 -0
  288. package/src/ipc-polling.ts +325 -0
  289. package/src/kimaki-digital-twin.e2e.test.ts +201 -0
  290. package/src/limit-heading-depth.test.ts +116 -0
  291. package/src/limit-heading-depth.ts +26 -0
  292. package/src/logger.ts +203 -0
  293. package/src/markdown.test.ts +360 -0
  294. package/src/markdown.ts +410 -0
  295. package/src/message-formatting.test.ts +81 -0
  296. package/src/message-formatting.ts +549 -0
  297. package/src/openai-realtime.ts +362 -0
  298. package/src/opencode-plugin-loading.e2e.test.ts +112 -0
  299. package/src/opencode-plugin.test.ts +108 -0
  300. package/src/opencode-plugin.ts +652 -0
  301. package/src/opencode.ts +554 -0
  302. package/src/privacy-sanitizer.ts +142 -0
  303. package/src/schema.sql +158 -0
  304. package/src/sentry.ts +137 -0
  305. package/src/session-handler/state.ts +232 -0
  306. package/src/session-handler.ts +2668 -0
  307. package/src/session-search.test.ts +50 -0
  308. package/src/session-search.ts +148 -0
  309. package/src/startup-service.ts +200 -0
  310. package/src/system-message.ts +568 -0
  311. package/src/task-runner.ts +425 -0
  312. package/src/task-schedule.test.ts +84 -0
  313. package/src/task-schedule.ts +287 -0
  314. package/src/thinking-utils.ts +61 -0
  315. package/src/thread-message-queue.e2e.test.ts +997 -0
  316. package/src/tools.ts +432 -0
  317. package/src/unnest-code-blocks.test.ts +679 -0
  318. package/src/unnest-code-blocks.ts +168 -0
  319. package/src/upgrade.ts +127 -0
  320. package/src/utils.ts +145 -0
  321. package/src/voice-handler.ts +852 -0
  322. package/src/voice.test.ts +219 -0
  323. package/src/voice.ts +444 -0
  324. package/src/wait-session.ts +147 -0
  325. package/src/worker-types.ts +64 -0
  326. package/src/worktree-utils.ts +988 -0
  327. package/src/xml.test.ts +38 -0
  328. package/src/xml.ts +121 -0
@@ -0,0 +1,997 @@
1
+ // E2e tests for per-thread message queue ordering (threadMessageQueue).
2
+ // Validates that messages in the same thread are processed sequentially
3
+ // in Discord arrival order, and that immediate interrupt allows
4
+ // queued messages to start without waiting for the full prior response.
5
+ //
6
+ // The threadMessageQueue (Map<string, Promise<void>>) only serializes messages
7
+ // arriving in threads — the initial text channel message goes through a separate
8
+ // code path (creates thread + calls handleOpencodeSession directly). So each
9
+ // test first establishes a session via the initial message, waits for the bot
10
+ // reply, then sends follow-up messages into the thread to exercise the queue.
11
+ //
12
+ // Bot replies may be error messages (e.g. "opencode session error: Not Found")
13
+ // rather than actual LLM content, depending on provider/session state. The
14
+ // tests verify ordering by message position, not content matching.
15
+
16
+ import fs from 'node:fs'
17
+ import path from 'node:path'
18
+ import url from 'node:url'
19
+ import { describe, beforeAll, afterAll, test, expect } from 'vitest'
20
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js'
21
+ import type { APIMessage } from 'discord.js'
22
+ import { DigitalDiscord } from 'discord-digital-twin/src'
23
+ import {
24
+ buildDeterministicOpencodeConfig,
25
+ type DeterministicMatcher,
26
+ } from 'opencode-deterministic-provider'
27
+ import {
28
+ getDefaultVerbosity,
29
+ setDataDir,
30
+ setDefaultVerbosity,
31
+ } from './config.js'
32
+ import { startDiscordBot } from './discord-bot.js'
33
+ import {
34
+ setBotToken,
35
+ initDatabase,
36
+ closeDatabase,
37
+ setChannelDirectory,
38
+ setChannelVerbosity,
39
+ getChannelVerbosity,
40
+ } from './database.js'
41
+ import { startHranaServer, stopHranaServer } from './hrana-server.js'
42
+ import { getOpencodeServers } from './opencode.js'
43
+
44
+ const e2eTest = describe
45
+
46
+ function createRunDirectories() {
47
+ const root = path.resolve(process.cwd(), 'tmp', 'thread-queue-e2e')
48
+ fs.mkdirSync(root, { recursive: true })
49
+
50
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'))
51
+ const projectDirectory = path.join(root, 'project')
52
+ fs.mkdirSync(projectDirectory, { recursive: true })
53
+
54
+ return { root, dataDir, projectDirectory }
55
+ }
56
+
57
+ function chooseLockPort() {
58
+ return 47_000 + (Date.now() % 2_000)
59
+ }
60
+
61
+ function createDiscordJsClient({ restUrl }: { restUrl: string }) {
62
+ return new Client({
63
+ intents: [
64
+ GatewayIntentBits.Guilds,
65
+ GatewayIntentBits.GuildMessages,
66
+ GatewayIntentBits.MessageContent,
67
+ GatewayIntentBits.GuildVoiceStates,
68
+ ],
69
+ partials: [
70
+ Partials.Channel,
71
+ Partials.Message,
72
+ Partials.User,
73
+ Partials.ThreadMember,
74
+ ],
75
+ rest: {
76
+ api: restUrl,
77
+ version: '10',
78
+ },
79
+ })
80
+ }
81
+
82
+ async function cleanupOpencodeServers() {
83
+ const servers = getOpencodeServers()
84
+ for (const [, server] of servers) {
85
+ if (!server.process.killed) {
86
+ server.process.kill('SIGTERM')
87
+ }
88
+ }
89
+ servers.clear()
90
+ }
91
+
92
+ function createDeterministicMatchers() {
93
+ const raceFinalReplyMatcher: DeterministicMatcher = {
94
+ id: 'race-final-reply',
95
+ priority: 110,
96
+ when: {
97
+ rawPromptIncludes: 'Reply with exactly: race-final',
98
+ },
99
+ then: {
100
+ parts: [
101
+ { type: 'stream-start', warnings: [] },
102
+ { type: 'text-start', id: 'race-final' },
103
+ { type: 'text-delta', id: 'race-final', delta: 'race-final' },
104
+ { type: 'text-end', id: 'race-final' },
105
+ {
106
+ type: 'finish',
107
+ finishReason: 'stop',
108
+ usage: {
109
+ inputTokens: 1,
110
+ outputTokens: 1,
111
+ totalTokens: 2,
112
+ },
113
+ },
114
+ ],
115
+ // Delay first output to widen the window where a stale idle could end
116
+ // this new request before it emits any assistant text.
117
+ partDelaysMs: [0, 2500, 0, 0, 0],
118
+ },
119
+ }
120
+
121
+ const sleepMatcher: DeterministicMatcher = {
122
+ id: 'sleep-tool-call',
123
+ priority: 100,
124
+ when: {
125
+ rawPromptIncludes:
126
+ 'MANDATORY INSTRUCTION: call the bash tool immediately and run exactly this command: `sleep 500`',
127
+ },
128
+ then: {
129
+ parts: [
130
+ { type: 'stream-start', warnings: [] },
131
+ { type: 'text-start', id: 'sleep-start' },
132
+ { type: 'text-delta', id: 'sleep-start', delta: 'running sleep 500' },
133
+ { type: 'text-end', id: 'sleep-start' },
134
+ {
135
+ type: 'tool-call',
136
+ toolCallId: 'sleep-call-1',
137
+ toolName: 'bash',
138
+ input: JSON.stringify({
139
+ command: 'sleep 500',
140
+ description: 'Deterministic sleep for interrupt e2e',
141
+ hasSideEffect: true,
142
+ }),
143
+ },
144
+ {
145
+ type: 'finish',
146
+ finishReason: 'tool-calls',
147
+ usage: {
148
+ inputTokens: 1,
149
+ outputTokens: 1,
150
+ totalTokens: 2,
151
+ },
152
+ },
153
+ ],
154
+ },
155
+ }
156
+
157
+ const toolFollowupMatcher: DeterministicMatcher = {
158
+ id: 'tool-followup',
159
+ priority: 50,
160
+ when: {
161
+ lastMessageRole: 'tool',
162
+ },
163
+ then: {
164
+ parts: [
165
+ { type: 'stream-start', warnings: [] },
166
+ { type: 'text-start', id: 'tool-followup' },
167
+ { type: 'text-delta', id: 'tool-followup', delta: 'tool done' },
168
+ { type: 'text-end', id: 'tool-followup' },
169
+ {
170
+ type: 'finish',
171
+ finishReason: 'stop',
172
+ usage: {
173
+ inputTokens: 1,
174
+ outputTokens: 1,
175
+ totalTokens: 2,
176
+ },
177
+ },
178
+ ],
179
+ },
180
+ }
181
+
182
+ const userReplyMatcher: DeterministicMatcher = {
183
+ id: 'user-reply',
184
+ priority: 10,
185
+ when: {
186
+ lastMessageRole: 'user',
187
+ rawPromptIncludes: 'Reply with exactly:',
188
+ },
189
+ then: {
190
+ parts: [
191
+ { type: 'stream-start', warnings: [] },
192
+ { type: 'text-start', id: 'default-reply' },
193
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
194
+ { type: 'text-end', id: 'default-reply' },
195
+ {
196
+ type: 'finish',
197
+ finishReason: 'stop',
198
+ usage: {
199
+ inputTokens: 1,
200
+ outputTokens: 1,
201
+ totalTokens: 2,
202
+ },
203
+ },
204
+ ],
205
+ partDelaysMs: [0, 700, 0, 0, 0],
206
+ },
207
+ }
208
+
209
+ return [
210
+ sleepMatcher,
211
+ raceFinalReplyMatcher,
212
+ toolFollowupMatcher,
213
+ userReplyMatcher,
214
+ ]
215
+ }
216
+
217
+ /** Poll getMessages until we see at least `count` bot messages. */
218
+ async function waitForBotMessageCount({
219
+ discord,
220
+ threadId,
221
+ count,
222
+ timeout,
223
+ }: {
224
+ discord: DigitalDiscord
225
+ threadId: string
226
+ count: number
227
+ timeout: number
228
+ }) {
229
+ const start = Date.now()
230
+ while (Date.now() - start < timeout) {
231
+ const messages = await discord.thread(threadId).getMessages()
232
+ const botMessages = messages.filter((m) => {
233
+ return m.author.id === discord.botUserId
234
+ })
235
+ if (botMessages.length >= count) {
236
+ return messages
237
+ }
238
+ await new Promise((r) => {
239
+ setTimeout(r, 500)
240
+ })
241
+ }
242
+ throw new Error(
243
+ `Timed out waiting for ${count} bot messages in thread ${threadId}`,
244
+ )
245
+ }
246
+
247
+ async function waitForBotReplyAfterUserMessage({
248
+ discord,
249
+ threadId,
250
+ userMessageIncludes,
251
+ timeout,
252
+ }: {
253
+ discord: DigitalDiscord
254
+ threadId: string
255
+ userMessageIncludes: string
256
+ timeout: number
257
+ }) {
258
+ const start = Date.now()
259
+ while (Date.now() - start < timeout) {
260
+ const messages = await discord.thread(threadId).getMessages()
261
+ const userMessageIndex = messages.findIndex((message) => {
262
+ return (
263
+ message.author.id === TEST_USER_ID &&
264
+ message.content.includes(userMessageIncludes)
265
+ )
266
+ })
267
+ const botReplyIndex = messages.findIndex((message, index) => {
268
+ return index > userMessageIndex && message.author.id === discord.botUserId
269
+ })
270
+ if (userMessageIndex >= 0 && botReplyIndex >= 0) {
271
+ return messages
272
+ }
273
+ await new Promise((resolve) => {
274
+ setTimeout(resolve, 500)
275
+ })
276
+ }
277
+ throw new Error(
278
+ `Timed out waiting for bot reply after user message containing "${userMessageIncludes}" in thread ${threadId}`,
279
+ )
280
+ }
281
+
282
+ async function waitForBotMessageContaining({
283
+ discord,
284
+ threadId,
285
+ text,
286
+ afterUserMessageIncludes,
287
+ timeout,
288
+ }: {
289
+ discord: DigitalDiscord
290
+ threadId: string
291
+ text: string
292
+ afterUserMessageIncludes?: string
293
+ timeout: number
294
+ }) {
295
+ const start = Date.now()
296
+ let lastMessages: APIMessage[] = []
297
+ while (Date.now() - start < timeout) {
298
+ const messages = await discord.thread(threadId).getMessages()
299
+ lastMessages = messages
300
+ const afterIndex = afterUserMessageIncludes
301
+ ? messages.findIndex((message) => {
302
+ return (
303
+ message.author.id === TEST_USER_ID &&
304
+ message.content.includes(afterUserMessageIncludes)
305
+ )
306
+ })
307
+ : -1
308
+ const match = messages.find((message, index) => {
309
+ if (afterUserMessageIncludes && afterIndex >= 0 && index <= afterIndex) {
310
+ return false
311
+ }
312
+ return (
313
+ message.author.id === discord.botUserId &&
314
+ message.content.includes(text)
315
+ )
316
+ })
317
+ if (match) {
318
+ return messages
319
+ }
320
+ await new Promise((resolve) => {
321
+ setTimeout(resolve, 500)
322
+ })
323
+ }
324
+ const recent = lastMessages
325
+ .slice(-12)
326
+ .map((message) => {
327
+ const role = message.author.id === discord.botUserId ? 'bot' : 'user'
328
+ return `${role}: ${message.content.slice(0, 120)}`
329
+ })
330
+ .join('\n')
331
+ throw new Error(
332
+ `Timed out waiting for bot message containing "${text}" in thread ${threadId}. Recent messages:\n${recent}`,
333
+ )
334
+ }
335
+
336
+ const TEST_USER_ID = '200000000000000777'
337
+ const TEXT_CHANNEL_ID = '200000000000000778'
338
+
339
+ e2eTest('thread message queue ordering', () => {
340
+ let directories: ReturnType<typeof createRunDirectories>
341
+ let discord: DigitalDiscord
342
+ let botClient: Client
343
+ let previousDefaultVerbosity: ReturnType<typeof getDefaultVerbosity> | null =
344
+ null
345
+
346
+ beforeAll(async () => {
347
+ directories = createRunDirectories()
348
+ const lockPort = chooseLockPort()
349
+
350
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
351
+ setDataDir(directories.dataDir)
352
+ previousDefaultVerbosity = getDefaultVerbosity()
353
+ setDefaultVerbosity('tools-and-text')
354
+
355
+ discord = new DigitalDiscord({
356
+ guild: {
357
+ name: 'Queue E2E Guild',
358
+ ownerId: TEST_USER_ID,
359
+ },
360
+ channels: [
361
+ {
362
+ id: TEXT_CHANNEL_ID,
363
+ name: 'queue-e2e',
364
+ type: ChannelType.GuildText,
365
+ },
366
+ ],
367
+ users: [
368
+ {
369
+ id: TEST_USER_ID,
370
+ username: 'queue-tester',
371
+ },
372
+ ],
373
+ })
374
+
375
+ await discord.start()
376
+
377
+ const providerNpm = url
378
+ .pathToFileURL(
379
+ path.resolve(
380
+ process.cwd(),
381
+ '..',
382
+ 'opencode-deterministic-provider',
383
+ 'src',
384
+ 'index.ts',
385
+ ),
386
+ )
387
+ .toString()
388
+
389
+ const opencodeConfig = buildDeterministicOpencodeConfig({
390
+ providerName: 'deterministic-provider',
391
+ providerNpm,
392
+ model: 'deterministic-v2',
393
+ smallModel: 'deterministic-v2',
394
+ settings: {
395
+ strict: false,
396
+ matchers: createDeterministicMatchers(),
397
+ },
398
+ })
399
+ fs.writeFileSync(
400
+ path.join(directories.projectDirectory, 'opencode.json'),
401
+ JSON.stringify(opencodeConfig, null, 2),
402
+ )
403
+
404
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
405
+ const hranaResult = await startHranaServer({ dbPath })
406
+ if (hranaResult instanceof Error) {
407
+ throw hranaResult
408
+ }
409
+ process.env['KIMAKI_DB_URL'] = hranaResult
410
+ await initDatabase()
411
+ await setBotToken(discord.botUserId, discord.botToken)
412
+
413
+ await setChannelDirectory({
414
+ channelId: TEXT_CHANNEL_ID,
415
+ directory: directories.projectDirectory,
416
+ channelType: 'text',
417
+ appId: discord.botUserId,
418
+ })
419
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools-and-text')
420
+ const channelVerbosity = await getChannelVerbosity(TEXT_CHANNEL_ID)
421
+ expect(channelVerbosity).toBe('tools-and-text')
422
+
423
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl })
424
+ await startDiscordBot({
425
+ token: discord.botToken,
426
+ appId: discord.botUserId,
427
+ discordClient: botClient,
428
+ })
429
+ }, 60_000)
430
+
431
+ afterAll(async () => {
432
+ if (botClient) {
433
+ botClient.destroy()
434
+ }
435
+
436
+ await cleanupOpencodeServers()
437
+ await Promise.all([
438
+ closeDatabase().catch(() => {
439
+ return
440
+ }),
441
+ stopHranaServer().catch(() => {
442
+ return
443
+ }),
444
+ discord?.stop().catch(() => {
445
+ return
446
+ }),
447
+ ])
448
+
449
+ delete process.env['KIMAKI_LOCK_PORT']
450
+ delete process.env['KIMAKI_DB_URL']
451
+ if (previousDefaultVerbosity) {
452
+ setDefaultVerbosity(previousDefaultVerbosity)
453
+ }
454
+ if (directories) {
455
+ fs.rmSync(directories.dataDir, { recursive: true, force: true })
456
+ }
457
+ }, 30_000)
458
+
459
+ test(
460
+ 'text message during active session gets processed',
461
+ async () => {
462
+ // 1. Send initial message to text channel → thread created + session established
463
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
464
+ content: 'Reply with exactly: alpha',
465
+ })
466
+
467
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
468
+ timeout: 60_000,
469
+ predicate: (t) => {
470
+ return t.name === 'Reply with exactly: alpha'
471
+ },
472
+ })
473
+
474
+ const th = discord.thread(thread.id)
475
+
476
+ // Wait for the first bot reply so session is fully established in DB
477
+ const firstReply = await th.waitForBotReply({
478
+ timeout: 120_000,
479
+ })
480
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
481
+
482
+ // Snapshot bot message count before sending follow-up
483
+ const before = await th.getMessages()
484
+ const beforeBotCount = before.filter((m) => {
485
+ return m.author.id === discord.botUserId
486
+ }).length
487
+
488
+ // 2. Send follow-up message B into the thread — goes through threadMessageQueue
489
+ await th.user(TEST_USER_ID).sendMessage({
490
+ content: 'Reply with exactly: beta',
491
+ })
492
+
493
+ // 3. Wait for exactly 1 new bot message (the response to B)
494
+ const after = await waitForBotMessageCount({
495
+ discord,
496
+ threadId: thread.id,
497
+ count: beforeBotCount + 1,
498
+ timeout: 120_000,
499
+ })
500
+
501
+ // 4. Verify at least 1 new bot message appeared for the follow-up.
502
+ // The bot may send additional messages per session (error reactions,
503
+ // session notifications) so we check >= not exact equality.
504
+ const afterBotMessages = after.filter((m) => {
505
+ return m.author.id === discord.botUserId
506
+ })
507
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
508
+
509
+ // User B's message must appear before the new bot response
510
+ const userBIndex = after.findIndex((m) => {
511
+ return (
512
+ m.author.id === TEST_USER_ID &&
513
+ m.content.includes('beta')
514
+ )
515
+ })
516
+ const lastBotIndex = after.findLastIndex((m) => {
517
+ return m.author.id === discord.botUserId
518
+ })
519
+
520
+ expect(userBIndex).toBeGreaterThan(-1)
521
+ expect(lastBotIndex).toBeGreaterThan(-1)
522
+ expect(userBIndex).toBeLessThan(lastBotIndex)
523
+
524
+ // New bot response has non-empty content
525
+ const newBotReply = afterBotMessages[afterBotMessages.length - 1]!
526
+ expect(newBotReply.content.trim().length).toBeGreaterThan(0)
527
+ },
528
+ 360_000,
529
+ )
530
+
531
+ test(
532
+ 'two rapid text messages in thread — both processed in order',
533
+ async () => {
534
+ // 1. Send initial message to text channel → thread + session established
535
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
536
+ content: 'Reply with exactly: one',
537
+ })
538
+
539
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
540
+ timeout: 60_000,
541
+ predicate: (t) => {
542
+ return t.name === 'Reply with exactly: one'
543
+ },
544
+ })
545
+
546
+ const th = discord.thread(thread.id)
547
+
548
+ // Wait for the first bot reply so session is established
549
+ const firstReply = await th.waitForBotReply({
550
+ timeout: 120_000,
551
+ })
552
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
553
+
554
+ // Snapshot bot message count before sending follow-ups
555
+ const before = await th.getMessages()
556
+ const beforeBotCount = before.filter((m) => {
557
+ return m.author.id === discord.botUserId
558
+ }).length
559
+
560
+ // 2. Rapidly send messages B and C — both go through threadMessageQueue
561
+ await th.user(TEST_USER_ID).sendMessage({
562
+ content: 'Reply with exactly: two',
563
+ })
564
+ await th.user(TEST_USER_ID).sendMessage({
565
+ content: 'Reply with exactly: three',
566
+ })
567
+
568
+ // 3. Wait for exactly 2 new bot messages (one per follow-up)
569
+ const after = await waitForBotMessageCount({
570
+ discord,
571
+ threadId: thread.id,
572
+ count: beforeBotCount + 2,
573
+ timeout: 120_000,
574
+ })
575
+
576
+ // 4. Verify at least 2 new bot messages appeared (one per follow-up).
577
+ // The bot may send additional messages per session (error reactions,
578
+ // session notifications) so we check >= not exact equality.
579
+ const afterBotMessages = after.filter((m) => {
580
+ return m.author.id === discord.botUserId
581
+ })
582
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 2)
583
+
584
+ // Each new bot message has non-empty content
585
+ const newBotReplies = afterBotMessages.slice(beforeBotCount)
586
+ for (const reply of newBotReplies) {
587
+ expect(reply.content.trim().length).toBeGreaterThan(0)
588
+ }
589
+
590
+ // 5. Verify per-follow-up causality: user B appears before 2nd bot
591
+ // message, user C appears before 3rd bot message
592
+ const botIndices = after.reduce<number[]>((acc, m, i) => {
593
+ if (m.author.id === discord.botUserId) {
594
+ acc.push(i)
595
+ }
596
+ return acc
597
+ }, [])
598
+
599
+ const userTwoIndex = after.findIndex((m) => {
600
+ return (
601
+ m.author.id === TEST_USER_ID &&
602
+ m.content.includes('two')
603
+ )
604
+ })
605
+ const userThreeIndex = after.findIndex((m) => {
606
+ return (
607
+ m.author.id === TEST_USER_ID &&
608
+ m.content.includes('three')
609
+ )
610
+ })
611
+
612
+ expect(userTwoIndex).toBeGreaterThan(-1)
613
+ expect(userThreeIndex).toBeGreaterThan(-1)
614
+
615
+ // Bot responses for B and C are the last 2 bot messages
616
+ const botForB = botIndices[botIndices.length - 2]!
617
+ const botForC = botIndices[botIndices.length - 1]!
618
+
619
+ // Each user message appears before its corresponding bot response
620
+ expect(userTwoIndex).toBeLessThan(botForB)
621
+ expect(userThreeIndex).toBeLessThan(botForC)
622
+
623
+ // Bot response for B appears before bot response for C (queue order)
624
+ expect(botForB).toBeLessThan(botForC)
625
+ },
626
+ 360_000,
627
+ )
628
+
629
+ test(
630
+ 'queued message aborts running session immediately',
631
+ async () => {
632
+ // When a new message queues behind a running session,
633
+ // signalThreadInterrupt aborts the in-flight session immediately,
634
+ // then the queue processes the next message.
635
+ //
636
+ // 1. Fast setup: establish session
637
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
638
+ content: 'Reply with exactly: delta',
639
+ })
640
+
641
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
642
+ timeout: 60_000,
643
+ predicate: (t) => {
644
+ return t.name === 'Reply with exactly: delta'
645
+ },
646
+ })
647
+
648
+ const th = discord.thread(thread.id)
649
+ const firstReply = await th.waitForBotReply({ timeout: 120_000 })
650
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
651
+
652
+ const before = await th.getMessages()
653
+ const beforeBotCount = before.filter((m) => {
654
+ return m.author.id === discord.botUserId
655
+ }).length
656
+
657
+ // 2. Send B, then quickly send C to trigger the interrupt.
658
+ // 200ms gap gives B time to enter the queue and start processing.
659
+ // signalThreadInterrupt aborts B immediately so C can run.
660
+ await th.user(TEST_USER_ID).sendMessage({
661
+ content: 'Reply with exactly: echo',
662
+ })
663
+ await new Promise((r) => {
664
+ setTimeout(r, 200)
665
+ })
666
+ await th.user(TEST_USER_ID).sendMessage({
667
+ content: 'Reply with exactly: foxtrot',
668
+ })
669
+
670
+ // 3. Poll until foxtrot's user message has a bot reply after it.
671
+ // waitForBotMessageCount alone isn't enough — error messages from the
672
+ // interrupted session can satisfy the count before foxtrot gets its reply.
673
+ const after = await waitForBotReplyAfterUserMessage({
674
+ discord,
675
+ threadId: thread.id,
676
+ userMessageIncludes: 'foxtrot',
677
+ timeout: 120_000,
678
+ })
679
+
680
+ // 4. Both B and C got bot responses
681
+ const afterBotMessages = after.filter((m) => {
682
+ return m.author.id === discord.botUserId
683
+ })
684
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 2)
685
+
686
+ const userEchoIndex = after.findIndex((m) => {
687
+ return m.author.id === TEST_USER_ID && m.content.includes('echo')
688
+ })
689
+ const userFoxtrotIndex = after.findIndex((m) => {
690
+ return m.author.id === TEST_USER_ID && m.content.includes('foxtrot')
691
+ })
692
+ expect(userEchoIndex).toBeGreaterThan(-1)
693
+ expect(userFoxtrotIndex).toBeGreaterThan(-1)
694
+
695
+ // Foxtrot's bot reply appears after the foxtrot user message
696
+ const botAfterFoxtrot = after.findIndex((m, i) => {
697
+ return i > userFoxtrotIndex && m.author.id === discord.botUserId
698
+ })
699
+ expect(botAfterFoxtrot).toBeGreaterThan(userFoxtrotIndex)
700
+ },
701
+ 360_000,
702
+ )
703
+
704
+ test(
705
+ 'slow stream still gets interrupted when no step-finish arrives',
706
+ async () => {
707
+ // With immediate abort, a queued message interrupts even while the previous
708
+ // request is mid-stream and has not reached a step-finish event.
709
+
710
+ // 1. Fast setup: establish session
711
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
712
+ content: 'Reply with exactly: golf',
713
+ })
714
+
715
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
716
+ timeout: 60_000,
717
+ predicate: (t) => {
718
+ return t.name === 'Reply with exactly: golf'
719
+ },
720
+ })
721
+
722
+ const th = discord.thread(thread.id)
723
+ const firstReply = await th.waitForBotReply({ timeout: 120_000 })
724
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
725
+
726
+ const before = await th.getMessages()
727
+ const beforeBotCount = before.filter((m) => {
728
+ return m.author.id === discord.botUserId
729
+ }).length
730
+
731
+ // 2. Start request B, then send C while B is still in progress.
732
+ await th.user(TEST_USER_ID).sendMessage({
733
+ content: 'Reply with exactly: hotel',
734
+ })
735
+
736
+ // 3. Wait briefly for B to start, then send C to trigger immediate abort
737
+ await new Promise((r) => {
738
+ setTimeout(r, 500)
739
+ })
740
+ await th.user(TEST_USER_ID).sendMessage({
741
+ content: 'Reply with exactly: india',
742
+ })
743
+
744
+ // 4. B is aborted and C gets processed.
745
+ // Poll until india's user message has a bot reply after it.
746
+ const after = await waitForBotReplyAfterUserMessage({
747
+ discord,
748
+ threadId: thread.id,
749
+ userMessageIncludes: 'india',
750
+ timeout: 120_000,
751
+ })
752
+
753
+ // C's user message appears before its bot response.
754
+ // The interrupted hotel session may or may not produce a visible bot message
755
+ // (depends on timing), so we only assert on india's reply existence.
756
+ const userIndiaIndex = after.findIndex((m) => {
757
+ return m.author.id === TEST_USER_ID && m.content.includes('india')
758
+ })
759
+ expect(userIndiaIndex).toBeGreaterThan(-1)
760
+ const botAfterIndia = after.findIndex((m, i) => {
761
+ return i > userIndiaIndex && m.author.id === discord.botUserId
762
+ })
763
+ expect(botAfterIndia).toBeGreaterThan(userIndiaIndex)
764
+ },
765
+ 360_000,
766
+ )
767
+
768
+ test(
769
+ 'queue drains correctly after interrupted session',
770
+ async () => {
771
+ // Verifies the queue doesn't get stuck after multiple interrupts.
772
+ // Rapidly sends B, C, D — each interrupts the previous. Then after all
773
+ // complete, sends E to prove the queue is clean and accepting new work.
774
+
775
+ // 1. Fast setup: establish session
776
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
777
+ content: 'Reply with exactly: juliet',
778
+ })
779
+
780
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
781
+ timeout: 60_000,
782
+ predicate: (t) => {
783
+ return t.name === 'Reply with exactly: juliet'
784
+ },
785
+ })
786
+
787
+ const th = discord.thread(thread.id)
788
+ const firstReply = await th.waitForBotReply({ timeout: 120_000 })
789
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
790
+
791
+ const before = await th.getMessages()
792
+ const beforeBotCount = before.filter((m) => {
793
+ return m.author.id === discord.botUserId
794
+ }).length
795
+
796
+ // 2. Rapidly send B, C, D — each queues behind the previous and triggers interrupt
797
+ await th.user(TEST_USER_ID).sendMessage({
798
+ content: 'Reply with exactly: kilo',
799
+ })
800
+ await new Promise((r) => {
801
+ setTimeout(r, 300)
802
+ })
803
+ await th.user(TEST_USER_ID).sendMessage({
804
+ content: 'Reply with exactly: lima',
805
+ })
806
+ await new Promise((r) => {
807
+ setTimeout(r, 300)
808
+ })
809
+ await th.user(TEST_USER_ID).sendMessage({
810
+ content: 'Reply with exactly: mike',
811
+ })
812
+
813
+ // 3. Wait until the last burst message (mike) has a bot reply after it.
814
+ const afterBurst = await waitForBotReplyAfterUserMessage({
815
+ discord,
816
+ threadId: thread.id,
817
+ userMessageIncludes: 'mike',
818
+ timeout: 120_000,
819
+ })
820
+
821
+ const burstBotMessages = afterBurst.filter((m) => {
822
+ return m.author.id === discord.botUserId
823
+ })
824
+ expect(burstBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
825
+
826
+ // 4. Queue should be clean — send E and verify it also gets processed
827
+ const burstBotCount = burstBotMessages.length
828
+
829
+ await th.user(TEST_USER_ID).sendMessage({
830
+ content: 'Reply with exactly: november',
831
+ })
832
+
833
+ const afterE = await waitForBotReplyAfterUserMessage({
834
+ discord,
835
+ threadId: thread.id,
836
+ userMessageIncludes: 'november',
837
+ timeout: 120_000,
838
+ })
839
+
840
+ const finalBotMessages = afterE.filter((m) => {
841
+ return m.author.id === discord.botUserId
842
+ })
843
+ expect(finalBotMessages.length).toBeGreaterThanOrEqual(burstBotCount)
844
+
845
+ // E's user message appears before the final bot response
846
+ const userNovemberIndex = afterE.findIndex((m) => {
847
+ return m.author.id === TEST_USER_ID && m.content.includes('november')
848
+ })
849
+ expect(userNovemberIndex).toBeGreaterThan(-1)
850
+ const lastBotIndex = afterE.findLastIndex((m) => {
851
+ return m.author.id === discord.botUserId
852
+ })
853
+ expect(userNovemberIndex).toBeLessThan(lastBotIndex)
854
+ },
855
+ 360_000,
856
+ )
857
+
858
+ test(
859
+ 'slow tool call (sleep) gets aborted when new message queues',
860
+ async () => {
861
+ // Tests that long-running tool calls get properly aborted when a new
862
+ // message queues behind them. During tool execution no step-finish events
863
+ // arrive, but interrupt should still abort immediately so the queue can
864
+ // process the next message normally.
865
+
866
+ // 1. Fast setup: establish session
867
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
868
+ content: 'Reply with exactly: oscar',
869
+ })
870
+
871
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
872
+ timeout: 60_000,
873
+ predicate: (t) => {
874
+ return t.name === 'Reply with exactly: oscar'
875
+ },
876
+ })
877
+
878
+ const th = discord.thread(thread.id)
879
+ const firstReply = await th.waitForBotReply({ timeout: 120_000 })
880
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
881
+
882
+ const before = await th.getMessages()
883
+ const beforeBotCount = before.filter((m) => {
884
+ return m.author.id === discord.botUserId
885
+ }).length
886
+
887
+ // 2. Ask the model to run a long sleep command
888
+ await th.user(TEST_USER_ID).sendMessage({
889
+ content:
890
+ 'MANDATORY INSTRUCTION: call the bash tool immediately and run exactly this command: `sleep 500`. No explanation. No normal text. Do not skip the tool call.',
891
+ })
892
+
893
+ // 3. Wait until we see the bash tool message for sleep, proving the tool
894
+ // call actually started before the interrupt message is sent.
895
+ await waitForBotMessageContaining({
896
+ discord,
897
+ threadId: thread.id,
898
+ text: 'sleep 500',
899
+ afterUserMessageIncludes: 'sleep 500',
900
+ timeout: 30_000,
901
+ })
902
+
903
+ // 4. Send interrupt message while sleep is still running
904
+ await th.user(TEST_USER_ID).sendMessage({
905
+ content: 'Reply with exactly: papa',
906
+ })
907
+
908
+ // 5. The interrupt aborts the sleep session, and the queue processes "papa".
909
+ const after = await waitForBotReplyAfterUserMessage({
910
+ discord,
911
+ threadId: thread.id,
912
+ userMessageIncludes: 'papa',
913
+ timeout: 120_000,
914
+ })
915
+
916
+ const afterBotMessages = after.filter((m) => {
917
+ return m.author.id === discord.botUserId
918
+ })
919
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
920
+
921
+ // Ensure sleep tool output appeared before the interrupt message.
922
+ const sleepToolIndex = after.findIndex((m) => {
923
+ return m.author.id === discord.botUserId && m.content.includes('sleep 500')
924
+ })
925
+ expect(sleepToolIndex).toBeGreaterThan(-1)
926
+
927
+ // "papa" user message appears before the last bot response
928
+ const userPapaIndex = after.findIndex((m) => {
929
+ return m.author.id === TEST_USER_ID && m.content.includes('papa')
930
+ })
931
+ expect(userPapaIndex).toBeGreaterThan(-1)
932
+ expect(sleepToolIndex).toBeLessThan(userPapaIndex)
933
+ const lastBotIndex = after.findLastIndex((m) => {
934
+ return m.author.id === discord.botUserId
935
+ })
936
+ expect(userPapaIndex).toBeLessThan(lastBotIndex)
937
+ },
938
+ 360_000,
939
+ )
940
+
941
+ async function runInterruptRaceScenario(runIndex: number) {
942
+ // Reproduces the stale-idle timing window reported in production:
943
+ // 1) an active stream is interrupted by a new message,
944
+ // 2) late events from the interrupted stream arrive,
945
+ // 3) the new prompt must still produce assistant text.
946
+ const setupPrompt = `Reply with exactly: race-setup-${runIndex}`
947
+ const raceFinalPrompt = `Reply with exactly: race-final-${runIndex}`
948
+
949
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
950
+ content: setupPrompt,
951
+ })
952
+
953
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
954
+ timeout: 60_000,
955
+ predicate: (t) => {
956
+ return t.name === setupPrompt
957
+ },
958
+ })
959
+
960
+ const th = discord.thread(thread.id)
961
+ const setupReply = await th.waitForBotReply({ timeout: 120_000 })
962
+ expect(setupReply.content.trim().length).toBeGreaterThan(0)
963
+
964
+ await th.user(TEST_USER_ID).sendMessage({
965
+ content:
966
+ 'MANDATORY INSTRUCTION: call the bash tool immediately and run exactly this command: `sleep 500`. No explanation. No normal text. Do not skip the tool call.',
967
+ })
968
+
969
+ await waitForBotMessageContaining({
970
+ discord,
971
+ threadId: thread.id,
972
+ text: 'sleep 500',
973
+ afterUserMessageIncludes: 'sleep 500',
974
+ timeout: 30_000,
975
+ })
976
+
977
+ await th.user(TEST_USER_ID).sendMessage({
978
+ content: raceFinalPrompt,
979
+ })
980
+
981
+ await waitForBotMessageContaining({
982
+ discord,
983
+ threadId: thread.id,
984
+ text: 'race-final',
985
+ afterUserMessageIncludes: raceFinalPrompt,
986
+ timeout: 30_000,
987
+ })
988
+ }
989
+
990
+ test(
991
+ 'interrupt race: queued message still gets assistant text after stale idle window',
992
+ async () => {
993
+ await runInterruptRaceScenario(1)
994
+ },
995
+ 360_000,
996
+ )
997
+ })