kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -5,14 +5,17 @@
5
5
 
6
6
  import { describe, test, expect, afterEach } from 'vitest'
7
7
  import { setupQueueAdvancedSuite, TEST_USER_ID } from './queue-advanced-e2e-setup.js'
8
- import { waitForBotMessageContaining, waitForFooterMessage } from './test-utils.js'
8
+ import {
9
+ waitForBotMessageContaining,
10
+ waitForBotReplyAfterUserMessage,
11
+ waitForFooterMessage,
12
+ } from './test-utils.js'
9
13
  import { store, type DeterministicTranscriptionConfig } from './store.js'
10
14
  import { getOpencodeClient } from './opencode.js'
11
15
  import { getThreadSession } from './database.js'
12
16
  import type { Message, Part } from '@opencode-ai/sdk/v2'
13
17
 
14
18
  const TEXT_CHANNEL_ID = '200000000000001007'
15
- const VOICE_CHANNEL_ID = '200000000000001017'
16
19
 
17
20
  function setDeterministicTranscription(config: DeterministicTranscriptionConfig | null) {
18
21
  store.setState({
@@ -158,37 +161,44 @@ describe('queue advanced: question tool answer', () => {
158
161
  // 1. Dismiss the pending question (cleanup context)
159
162
  // 2. Abort the blocked session so OpenCode unblocks
160
163
  // 3. Enqueue the message as a normal user prompt (not consumed as answer)
164
+ const answer = 'Reply with exactly: question-text-answer-done'
161
165
  await th.user(TEST_USER_ID).sendMessage({
162
- content: 'my text answer',
166
+ content: answer,
163
167
  })
164
168
 
165
- // Give time for question cleanup to propagate
166
- await new Promise((r) => {
167
- setTimeout(r, 1_000)
169
+ await waitForBotReplyAfterUserMessage({
170
+ discord: ctx.discord,
171
+ threadId: thread.id,
172
+ userId: TEST_USER_ID,
173
+ userMessageIncludes: answer,
174
+ timeout: 8_000,
175
+ })
176
+ await waitForBotMessageContaining({
177
+ discord: ctx.discord,
178
+ threadId: thread.id,
179
+ userId: TEST_USER_ID,
180
+ text: 'ok',
181
+ afterUserMessageIncludes: answer,
182
+ timeout: 8_000,
183
+ })
184
+ await waitForFooterMessage({
185
+ discord: ctx.discord,
186
+ threadId: thread.id,
187
+ timeout: 8_000,
188
+ afterMessageIncludes: 'ok',
189
+ afterAuthorId: ctx.discord.botUserId,
168
190
  })
169
191
 
170
192
  const timeline = await th.text({ showInteractions: true })
171
193
 
172
194
  // The user's text answer must appear in Discord
173
- expect(timeline).toContain('my text answer')
195
+ expect(timeline).toContain(answer)
196
+ expect(timeline).toContain('⬥ ok')
174
197
  // The original question must have appeared
175
198
  expect(timeline).toContain('Which option do you prefer?')
176
199
  // The user's marker message triggered the question
177
200
  expect(timeline).toContain('QUESTION_TEXT_ANSWER_MARKER')
178
201
  }, 20_000)
179
- })
180
-
181
- describe('queue advanced: voice message during pending question', () => {
182
- const ctx = setupQueueAdvancedSuite({
183
- channelId: VOICE_CHANNEL_ID,
184
- channelName: 'qa-question-voice-e2e',
185
- dirName: 'qa-question-voice-e2e',
186
- username: 'queue-question-tester',
187
- })
188
-
189
- afterEach(() => {
190
- setDeterministicTranscription(null)
191
- })
192
202
 
193
203
  test('voice message during pending question dismisses question and transcribes normally', async () => {
194
204
  // This is the exact bug scenario: user sends a voice message while a
@@ -196,14 +206,15 @@ describe('queue advanced: voice message during pending question', () => {
196
206
  // (audio is in attachments, transcription happens later). The old code
197
207
  // passed "" as the question answer and consumed the message — the voice
198
208
  // content was completely lost.
199
- await ctx.discord.channel(VOICE_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
200
- content: 'QUESTION_TEXT_ANSWER_MARKER',
209
+ const marker = 'QUESTION_TEXT_ANSWER_MARKER voice'
210
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
211
+ content: marker,
201
212
  })
202
213
 
203
- const thread = await ctx.discord.channel(VOICE_CHANNEL_ID).waitForThread({
214
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
204
215
  timeout: 8_000,
205
216
  predicate: (t) => {
206
- return t.name === 'QUESTION_TEXT_ANSWER_MARKER'
217
+ return t.name === marker
207
218
  },
208
219
  })
209
220
 
@@ -230,11 +241,6 @@ describe('queue advanced: voice message during pending question', () => {
230
241
  content: 'VOICE_TEXT_CONTENT_SHOULD_NOT_REACH_MODEL',
231
242
  })
232
243
 
233
- // Give time for question cleanup to propagate
234
- await new Promise((r) => {
235
- setTimeout(r, 1_000)
236
- })
237
-
238
244
  // Voice content should be transcribed and appear as the next user message,
239
245
  // processed after the model responds to the empty question answer.
240
246
  await waitForBotMessageContaining({
@@ -108,7 +108,7 @@ e2eTest('queue advanced: typing interrupt', () => {
108
108
  --- from: assistant (TestBot)
109
109
  *using deterministic-provider/deterministic-v2*
110
110
  ⬥ ok
111
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
111
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
112
112
  --- from: user (queue-advanced-tester)
113
113
  PLUGIN_TIMEOUT_SLEEP_MARKER
114
114
  --- from: assistant (TestBot)
@@ -117,7 +117,7 @@ e2eTest('queue advanced: typing interrupt', () => {
117
117
  Reply with exactly: typing-stop-interrupt-final
118
118
  --- from: assistant (TestBot)
119
119
  ⬥ ok
120
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
120
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
121
121
  `)
122
122
 
123
123
  const timeline = await th.text({ showTyping: true })
@@ -90,7 +90,7 @@ e2eTest('queue delete message', () => {
90
90
  Queued at position 1. Edit or delete your message to update the queue
91
91
  ⬦ **queue-delete-tester** removed message from queue
92
92
  ⬥ slow-busy-reply
93
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
93
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
94
94
  `)
95
95
  const finalText = await th.text()
96
96
  expect(finalText).not.toContain(
@@ -139,12 +139,12 @@ describe('queue drain with pending interactive UI', () => {
139
139
  --- from: assistant (TestBot)
140
140
  *using deterministic-provider/deterministic-v2*
141
141
  ⬥ ok
142
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
142
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
143
143
  **Action Required**
144
144
  [user interaction]
145
145
  » **drain-ui-tester:** Reply with exactly: post-button-drain
146
146
  ⬥ ok
147
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
147
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
148
148
  `)
149
149
  },
150
150
  20_000,
@@ -23,6 +23,9 @@ import {
23
23
  waitForBotMessageContaining,
24
24
  waitForMessageById,
25
25
  } from './test-utils.js'
26
+ import { getThreadSession } from './database.js'
27
+ import { archiveThread } from './discord-utils.js'
28
+ import { getOpencodeClient } from './opencode.js'
26
29
 
27
30
  const TEXT_CHANNEL_ID = '200000000000001099'
28
31
 
@@ -36,6 +39,124 @@ e2eTest('queue + interrupt drain ordering', () => {
36
39
  username: 'interrupt-tester',
37
40
  })
38
41
 
42
+ test(
43
+ 'archiving keeps the active run alive so queued messages drain',
44
+ async () => {
45
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
46
+ content: 'Reply with exactly: setup-archive-drain',
47
+ })
48
+
49
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
50
+ timeout: 4_000,
51
+ predicate: (candidate) => {
52
+ return candidate.name === 'Reply with exactly: setup-archive-drain'
53
+ },
54
+ })
55
+ const th = ctx.discord.thread(thread.id)
56
+ await th.waitForBotReply({ timeout: 4_000 })
57
+ await waitForFooterMessage({
58
+ discord: ctx.discord,
59
+ threadId: thread.id,
60
+ timeout: 4_000,
61
+ })
62
+
63
+ await th.user(TEST_USER_ID).sendMessage({
64
+ content: 'PLUGIN_TIMEOUT_SLEEP_MARKER archive queue drain',
65
+ })
66
+ await waitForBotMessageContaining({
67
+ discord: ctx.discord,
68
+ threadId: thread.id,
69
+ userId: TEST_USER_ID,
70
+ text: 'starting sleep',
71
+ afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER archive queue drain',
72
+ timeout: 4_000,
73
+ })
74
+
75
+ const { id: queueInteractionId } = await th.user(TEST_USER_ID)
76
+ .runSlashCommand({
77
+ name: 'queue',
78
+ options: [{
79
+ name: 'message',
80
+ type: 3,
81
+ value: 'Reply with exactly: archived-queue-survives',
82
+ }],
83
+ })
84
+ const queueAck = await th.waitForInteractionAck({
85
+ interactionId: queueInteractionId,
86
+ timeout: 4_000,
87
+ })
88
+ if (!queueAck.messageId) {
89
+ throw new Error('Expected /queue response message id')
90
+ }
91
+ const queueStatusMessage = await waitForMessageById({
92
+ discord: ctx.discord,
93
+ threadId: thread.id,
94
+ messageId: queueAck.messageId,
95
+ timeout: 4_000,
96
+ })
97
+ expect(queueStatusMessage.content).toContain('Queued message')
98
+
99
+ const sessionId = await getThreadSession(thread.id)
100
+ if (!sessionId) throw new Error('Expected mapped OpenCode session')
101
+ const client = getOpencodeClient(ctx.directories.projectDirectory)
102
+ if (!client) throw new Error('Expected initialized OpenCode client')
103
+ await archiveThread({
104
+ rest: ctx.botClient.rest,
105
+ threadId: thread.id,
106
+ parentChannelId: TEXT_CHANNEL_ID,
107
+ sessionId,
108
+ client,
109
+ })
110
+
111
+ const statusResponse = await client.session.status({
112
+ directory: ctx.directories.projectDirectory,
113
+ })
114
+ expect(statusResponse.data?.[sessionId]?.type).toBe('busy')
115
+
116
+ await th.user(TEST_USER_ID).sendMessage({
117
+ content: 'Reply with exactly: continue-after-archive',
118
+ })
119
+ await waitForFooterMessage({
120
+ discord: ctx.discord,
121
+ threadId: thread.id,
122
+ timeout: 12_000,
123
+ afterMessageIncludes: 'continue-after-archive',
124
+ afterAuthorId: TEST_USER_ID,
125
+ })
126
+
127
+ await waitForFooterMessage({
128
+ discord: ctx.discord,
129
+ threadId: thread.id,
130
+ timeout: 12_000,
131
+ afterMessageIncludes: 'archived-queue-survives',
132
+ afterAuthorId: ctx.discord.botUserId,
133
+ })
134
+
135
+ expect(await th.text()).toMatchInlineSnapshot(`
136
+ "--- from: user (interrupt-tester)
137
+ Reply with exactly: setup-archive-drain
138
+ --- from: assistant (TestBot)
139
+ *using deterministic-provider/deterministic-v2*
140
+ ⬥ ok
141
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
142
+ --- from: user (interrupt-tester)
143
+ PLUGIN_TIMEOUT_SLEEP_MARKER archive queue drain
144
+ --- from: assistant (TestBot)
145
+ ⬥ starting sleep 100
146
+ Queued message (position 1)
147
+ --- from: user (interrupt-tester)
148
+ Reply with exactly: continue-after-archive
149
+ --- from: assistant (TestBot)
150
+ ⬥ ok
151
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
152
+ » **interrupt-tester:** Reply with exactly: archived-queue-survives
153
+ ⬥ ok
154
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
155
+ `)
156
+ },
157
+ 20_000,
158
+ )
159
+
39
160
  test(
40
161
  'queued message via /queue + normal interrupt: interrupt reply should appear, then queue drains',
41
162
  async () => {
@@ -125,7 +246,7 @@ e2eTest('queue + interrupt drain ordering', () => {
125
246
  --- from: assistant (TestBot)
126
247
  *using deterministic-provider/deterministic-v2*
127
248
  ⬥ ok
128
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
249
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
129
250
  --- from: user (interrupt-tester)
130
251
  PLUGIN_TIMEOUT_SLEEP_MARKER
131
252
  --- from: assistant (TestBot)
@@ -138,7 +259,7 @@ e2eTest('queue + interrupt drain ordering', () => {
138
259
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
139
260
  » **interrupt-tester:** Reply with exactly: queued-behind-slow
140
261
  ⬥ ok
141
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
262
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
142
263
  `)
143
264
 
144
265
  // 7. Assert the interrupt message got its own ⬥ ok reply between the
@@ -1,7 +1,4 @@
1
- // E2e test: queued message must drain after the user answers a pending question
2
- // via the Discord dropdown select menu. Reproduces a bug where answering via
3
- // select (not text) leaves queued messages stuck because the session continues
4
- // processing after the answer and may enter another blocking state.
1
+ // E2e test for ordered queue drain after a pending question is answered via select.
5
2
 
6
3
  import { describe, test, expect } from 'vitest'
7
4
  import {
@@ -53,10 +50,7 @@ async function expectNoBotMessageContaining({
53
50
  while (Date.now() - start < timeout) {
54
51
  const messages = await discord.thread(threadId).getMessages()
55
52
  const match = messages.find((message) => {
56
- return (
57
- message.author.id === discord.botUserId
58
- && message.content.includes(text)
59
- )
53
+ return message.author.id === discord.botUserId && message.content.includes(text)
60
54
  })
61
55
  if (match) {
62
56
  throw new Error(
@@ -78,132 +72,19 @@ describe('queue drain after question select answer', () => {
78
72
  })
79
73
 
80
74
  test(
81
- 'queued message drains after answering question via dropdown select',
75
+ 'queued messages drain in order after answering via dropdown select',
82
76
  async () => {
83
- // 1. Send a message that triggers the question tool
84
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
85
- content: 'QUESTION_SELECT_QUEUE_MARKER',
86
- })
87
-
88
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
89
- timeout: 8_000,
90
- predicate: (t) => {
91
- return t.name === 'QUESTION_SELECT_QUEUE_MARKER'
92
- },
93
- })
94
-
95
- const th = ctx.discord.thread(thread.id)
96
-
97
- // 2. Wait for the question dropdown message to appear in Discord.
98
- // Uses visible message wait instead of internal Map polling which
99
- // is too timing-sensitive on CI.
100
- const questionMessages = await waitForBotMessageContaining({
101
- discord: ctx.discord,
102
- threadId: thread.id,
103
- text: 'How to proceed?',
104
- timeout: 12_000,
105
- })
106
-
107
- // Get the pending question context hash from the internal map.
108
- // By this point the question message is visible so the context must exist.
109
- const pending = await waitForPendingQuestion({
110
- threadId: thread.id,
111
- timeoutMs: 8_000,
112
- })
113
- const questionMsg = questionMessages.find((m) => {
114
- return m.content.includes('How to proceed?')
115
- })!
116
- expect(questionMsg).toBeTruthy()
117
-
118
- // 3. Queue a message while question is pending
119
- const { id: queueInteractionId } = await th.user(TEST_USER_ID)
120
- .runSlashCommand({
121
- name: 'queue',
122
- options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-question-drain' }],
123
- })
124
-
125
- const queueAck = await th.waitForInteractionAck({
126
- interactionId: queueInteractionId,
127
- timeout: 8_000,
128
- })
129
- if (!queueAck.messageId) {
130
- throw new Error('Expected /queue response message id')
131
- }
132
-
133
- // 4. The first queued item should be handed off immediately even while
134
- // the question is still pending, so the visible dispatch indicator
135
- // appears before the user answers the dropdown.
136
- await waitForBotMessageContaining({
137
- discord: ctx.discord,
138
- threadId: thread.id,
139
- text: '» **question-select-tester:** Reply with exactly: post-question-drain',
140
- timeout: 8_000,
141
- })
142
-
143
- // 5. Answer the question via dropdown select (pick first option "Alpha")
144
- const interaction = await th.user(TEST_USER_ID).selectMenu({
145
- messageId: questionMsg.id,
146
- customId: `ask_question:${pending.contextHash}:0`,
147
- values: ['0'],
148
- })
149
-
150
- await th.waitForInteractionAck({
151
- interactionId: interaction.id,
152
- timeout: 8_000,
153
- })
154
-
155
- // 6. Wait for footer from the drained queued message
156
- await waitForFooterMessage({
157
- discord: ctx.discord,
158
- threadId: thread.id,
159
- timeout: 8_000,
160
- afterMessageIncludes: '» **question-select-tester:**',
161
- afterAuthorId: ctx.discord.botUserId,
162
- })
163
-
164
- const timeline = await th.text({ showInteractions: true })
165
- expect(timeline).toMatchInlineSnapshot(`
166
- "--- from: user (question-select-tester)
167
- QUESTION_SELECT_QUEUE_MARKER
168
- --- from: assistant (TestBot)
169
- *using deterministic-provider/deterministic-v2*
170
- **Select action**
171
- How to proceed?
172
- ✓ _Alpha_
173
- [user interaction]
174
- » **question-select-tester:** Reply with exactly: post-question-drain
175
- Queued message (position 1)
176
- [user selects dropdown: 0]
177
- » **question-select-tester:** Alpha
178
- ⬥ ok
179
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
180
- `)
181
- expect(timeline).toContain('QUESTION_SELECT_QUEUE_MARKER')
182
- expect(timeline).toContain('How to proceed?')
183
- expect(timeline).toContain('[user selects dropdown: 0]')
184
- expect(timeline).toContain('» **question-select-tester:** Reply with exactly: post-question-drain')
185
- expect(timeline).toContain('⬥ ok')
186
- expect(timeline).toContain('*project ⋅ main ⋅')
187
- },
188
- 20_000,
189
- )
190
-
191
- test(
192
- 'only the first queued message is handed off after dropdown answer',
193
- async () => {
194
- const marker = 'QUESTION_SELECT_QUEUE_MARKER second-test'
195
-
77
+ const marker = 'QUESTION_SELECT_QUEUE_MARKER'
196
78
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
197
79
  content: marker,
198
80
  })
199
81
 
200
82
  const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
201
83
  timeout: 8_000,
202
- predicate: (t) => {
203
- return t.name === marker
84
+ predicate: (candidate) => {
85
+ return candidate.name === marker
204
86
  },
205
87
  })
206
-
207
88
  const th = ctx.discord.thread(thread.id)
208
89
 
209
90
  const questionMessages = await waitForBotMessageContaining({
@@ -212,21 +93,18 @@ describe('queue drain after question select answer', () => {
212
93
  text: 'How to proceed?',
213
94
  timeout: 12_000,
214
95
  })
215
-
216
96
  const pending = await waitForPendingQuestion({
217
97
  threadId: thread.id,
218
98
  timeoutMs: 8_000,
219
99
  })
220
-
221
- const questionMsg = questionMessages.find((message) => {
100
+ const questionMessage = questionMessages.find((message) => {
222
101
  return message.content.includes('How to proceed?')
223
102
  })
224
- expect(questionMsg).toBeTruthy()
225
- if (!questionMsg) {
103
+ if (!questionMessage) {
226
104
  throw new Error('Expected question message')
227
105
  }
228
106
 
229
- const firstQueuedPrompt = 'SLOW_ABORT_MARKER run long response'
107
+ const firstQueuedPrompt = 'QUESTION_SELECT_DRAIN_FIRST_MARKER'
230
108
  const secondQueuedPrompt = 'Reply with exactly: post-question-second'
231
109
 
232
110
  const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
@@ -234,48 +112,45 @@ describe('queue drain after question select answer', () => {
234
112
  name: 'queue',
235
113
  options: [{ name: 'message', type: 3, value: firstQueuedPrompt }],
236
114
  })
237
-
238
115
  await th.waitForInteractionAck({
239
116
  interactionId: firstQueueInteractionId,
240
117
  timeout: 8_000,
241
118
  })
242
119
 
120
+ await waitForBotMessageContaining({
121
+ discord: ctx.discord,
122
+ threadId: thread.id,
123
+ text: `» **question-select-tester:** ${firstQueuedPrompt}`,
124
+ timeout: 8_000,
125
+ })
126
+
243
127
  const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
244
128
  .runSlashCommand({
245
129
  name: 'queue',
246
130
  options: [{ name: 'message', type: 3, value: secondQueuedPrompt }],
247
131
  })
248
-
249
132
  await th.waitForInteractionAck({
250
133
  interactionId: secondQueueInteractionId,
251
134
  timeout: 8_000,
252
135
  })
253
136
 
137
+ await expectNoBotMessageContaining({
138
+ discord: ctx.discord,
139
+ threadId: thread.id,
140
+ text: `» **question-select-tester:** ${secondQueuedPrompt}`,
141
+ timeout: 200,
142
+ })
143
+
254
144
  const interaction = await th.user(TEST_USER_ID).selectMenu({
255
- messageId: questionMsg.id,
145
+ messageId: questionMessage.id,
256
146
  customId: `ask_question:${pending.contextHash}:0`,
257
147
  values: ['0'],
258
148
  })
259
-
260
149
  await th.waitForInteractionAck({
261
150
  interactionId: interaction.id,
262
151
  timeout: 8_000,
263
152
  })
264
153
 
265
- await waitForBotMessageContaining({
266
- discord: ctx.discord,
267
- threadId: thread.id,
268
- text: `» **question-select-tester:** ${firstQueuedPrompt}`,
269
- timeout: 8_000,
270
- })
271
-
272
- await expectNoBotMessageContaining({
273
- discord: ctx.discord,
274
- threadId: thread.id,
275
- text: `» **question-select-tester:** ${secondQueuedPrompt}`,
276
- timeout: 200,
277
- })
278
-
279
154
  await waitForFooterMessage({
280
155
  discord: ctx.discord,
281
156
  threadId: thread.id,
@@ -283,14 +158,12 @@ describe('queue drain after question select answer', () => {
283
158
  afterMessageIncludes: `» **question-select-tester:** ${firstQueuedPrompt}`,
284
159
  afterAuthorId: ctx.discord.botUserId,
285
160
  })
286
-
287
161
  await waitForBotMessageContaining({
288
162
  discord: ctx.discord,
289
163
  threadId: thread.id,
290
164
  text: `» **question-select-tester:** ${secondQueuedPrompt}`,
291
165
  timeout: 8_000,
292
166
  })
293
-
294
167
  await waitForFooterMessage({
295
168
  discord: ctx.discord,
296
169
  threadId: thread.id,
@@ -302,30 +175,32 @@ describe('queue drain after question select answer', () => {
302
175
  const timeline = await th.text({ showInteractions: true })
303
176
  expect(timeline).toMatchInlineSnapshot(`
304
177
  "--- from: user (question-select-tester)
305
- QUESTION_SELECT_QUEUE_MARKER second-test
178
+ QUESTION_SELECT_QUEUE_MARKER
306
179
  --- from: assistant (TestBot)
307
180
  *using deterministic-provider/deterministic-v2*
308
181
  **Select action**
309
182
  How to proceed?
310
183
  ✓ _Alpha_
311
184
  [user interaction]
312
- » **question-select-tester:** SLOW_ABORT_MARKER run long response
185
+ » **question-select-tester:** QUESTION_SELECT_DRAIN_FIRST_MARKER
313
186
  Queued message (position 1)
314
187
  [user interaction]
315
188
  Queued message (position 1)
316
189
  [user selects dropdown: 0]
317
190
  » **question-select-tester:** Alpha
318
- ⬥ slow-response-started
191
+ ⬥ question-drain-first
319
192
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
320
193
  » **question-select-tester:** Reply with exactly: post-question-second
321
194
  ⬥ ok
322
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
195
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
323
196
  `)
197
+ expect(timeline).toContain('How to proceed?')
198
+ expect(timeline).toContain('[user selects dropdown: 0]')
324
199
  expect(timeline).toContain(`» **question-select-tester:** ${firstQueuedPrompt}`)
325
- expect(timeline).toContain('⬥ slow-response-started')
200
+ expect(timeline).toContain('⬥ question-drain-first')
326
201
  expect(timeline).toContain(`» **question-select-tester:** ${secondQueuedPrompt}`)
327
202
  expect(timeline).toContain('⬥ ok')
328
203
  },
329
- 20_000,
204
+ 15_000,
330
205
  )
331
206
  })