kimaki 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -0,0 +1,89 @@
1
+ // E2e test: aborting a run must kill the pending question dropdown.
2
+ // Questions have no TTL, so abort is the only thing that clears them while the
3
+ // thread stays alive. Without this, a stale dropdown keeps accepting clicks and
4
+ // answers a question whose run is already dead.
5
+ import { describe, test, expect } from 'vitest';
6
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
7
+ import { waitForBotMessageContaining } from './test-utils.js';
8
+ import { pendingQuestionContexts } from './commands/ask-question.js';
9
+ import { getRuntime } from './session-handler/thread-session-runtime.js';
10
+ const TEXT_CHANNEL_ID = '200000000000001031';
11
+ describe('abort clears pending question dropdown', () => {
12
+ const ctx = setupQueueAdvancedSuite({
13
+ channelId: TEXT_CHANNEL_ID,
14
+ channelName: 'qa-question-abort',
15
+ dirName: 'qa-question-abort',
16
+ username: 'question-abort-tester',
17
+ });
18
+ test('dropdown stops accepting answers after abort', async () => {
19
+ const marker = 'QUESTION_SELECT_QUEUE_MARKER abort-test';
20
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
21
+ content: marker,
22
+ });
23
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
24
+ timeout: 8_000,
25
+ predicate: (t) => {
26
+ return t.name === marker;
27
+ },
28
+ });
29
+ const th = ctx.discord.thread(thread.id);
30
+ const questionMessages = await waitForBotMessageContaining({
31
+ discord: ctx.discord,
32
+ threadId: thread.id,
33
+ text: 'How to proceed?',
34
+ timeout: 12_000,
35
+ });
36
+ const questionMsg = questionMessages.find((message) => {
37
+ return message.content.includes('How to proceed?');
38
+ });
39
+ if (!questionMsg) {
40
+ throw new Error('Expected question message');
41
+ }
42
+ const pendingEntry = [...pendingQuestionContexts.entries()].find(([, context]) => {
43
+ return context.thread.id === thread.id;
44
+ });
45
+ if (!pendingEntry) {
46
+ throw new Error('Expected pending question context');
47
+ }
48
+ const contextHash = pendingEntry[0];
49
+ const runtime = getRuntime(thread.id);
50
+ if (!runtime) {
51
+ throw new Error('Expected runtime for question abort test');
52
+ }
53
+ runtime.abortActiveRun('test-question-abort');
54
+ // Click the now-dead dropdown. The answer echo must never appear.
55
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
56
+ messageId: questionMsg.id,
57
+ customId: `ask_question:${contextHash}:0`,
58
+ values: ['0'],
59
+ });
60
+ await th.waitForInteractionAck({
61
+ interactionId: interaction.id,
62
+ timeout: 8_000,
63
+ });
64
+ for (let i = 0; i < 10; i++) {
65
+ const messages = await th.getMessages();
66
+ const answered = messages.find((message) => {
67
+ return message.content.includes('» **question-abort-tester:** Alpha');
68
+ });
69
+ if (answered) {
70
+ throw new Error('Dropdown was still answerable after abort');
71
+ }
72
+ await new Promise((resolve) => {
73
+ setTimeout(resolve, 20);
74
+ });
75
+ }
76
+ const timeline = await th.text({ showInteractions: true });
77
+ expect(timeline).toMatchInlineSnapshot(`
78
+ "--- from: user (question-abort-tester)
79
+ QUESTION_SELECT_QUEUE_MARKER abort-test
80
+ --- from: assistant (TestBot)
81
+ > *using deterministic-provider/deterministic-v2*
82
+ **Select action**
83
+ How to proceed?
84
+ [user selects dropdown: 0]
85
+ This question has expired. Please ask the AI again."
86
+ `);
87
+ expect(pendingQuestionContexts.size).toBe(0);
88
+ }, 20_000);
89
+ });
@@ -0,0 +1,150 @@
1
+ // E2e: leftover aborted text must post before a later question dropdown.
2
+ // Abort leaves an open text part buffered under the first assistant message.
3
+ // The retry is a new user turn, so a single-id / current-turn flush misses it.
4
+ import { describe, test, expect } from 'vitest';
5
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
6
+ import { getRuntime } from './session-handler/thread-session-runtime.js';
7
+ import { getMessageVisibleText, waitForBotMessageContaining } from './test-utils.js';
8
+ const TEXT_CHANNEL_ID = '200000000000001043';
9
+ function createAbortRetryQuestionMatchers() {
10
+ const leftoverOpenTextMatcher = {
11
+ id: 'abort-retry-leftover-open-text',
12
+ priority: 130,
13
+ when: {
14
+ lastMessageRole: 'user',
15
+ latestUserTextIncludes: 'ABORT_RETRY_LEFTOVER_TEXT_MARKER',
16
+ },
17
+ then: {
18
+ parts: [
19
+ { type: 'stream-start', warnings: [] },
20
+ { type: 'text-start', id: 'abort-retry-started' },
21
+ {
22
+ type: 'text-delta',
23
+ id: 'abort-retry-started',
24
+ delta: 'abort-retry-started',
25
+ },
26
+ { type: 'text-end', id: 'abort-retry-started' },
27
+ { type: 'text-start', id: 'abort-retry-leftover' },
28
+ {
29
+ type: 'text-delta',
30
+ id: 'abort-retry-leftover',
31
+ delta: 'LEFTOVER_ABORTED_TEXT',
32
+ },
33
+ {
34
+ type: 'finish',
35
+ finishReason: 'stop',
36
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
37
+ },
38
+ ],
39
+ partDelaysMs: [0, 0, 0, 0, 0, 0, 100_000],
40
+ },
41
+ };
42
+ const questionAfterAbortMatcher = {
43
+ id: 'question-after-abort-retry',
44
+ priority: 131,
45
+ when: {
46
+ lastMessageRole: 'user',
47
+ latestUserTextIncludes: 'QUESTION_AFTER_ABORT_RETRY_MARKER',
48
+ },
49
+ then: {
50
+ parts: [
51
+ { type: 'stream-start', warnings: [] },
52
+ { type: 'text-start', id: 'plan-after-abort' },
53
+ {
54
+ type: 'text-delta',
55
+ id: 'plan-after-abort',
56
+ delta: 'PLAN_AFTER_ABORT_RETRY',
57
+ },
58
+ {
59
+ type: 'tool-call',
60
+ toolCallId: 'question-after-abort-call',
61
+ toolName: 'question',
62
+ input: JSON.stringify({
63
+ questions: [{
64
+ question: 'What next after abort?',
65
+ header: 'Next step',
66
+ options: [
67
+ { label: 'Commit', description: 'Commit these files' },
68
+ { label: 'Stop', description: 'Leave uncommitted' },
69
+ ],
70
+ }],
71
+ }),
72
+ },
73
+ { type: 'text-end', id: 'plan-after-abort' },
74
+ {
75
+ type: 'finish',
76
+ finishReason: 'tool-calls',
77
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
78
+ },
79
+ ],
80
+ partDelaysMs: [200, 0, 0, 0, 400, 0],
81
+ },
82
+ };
83
+ return [leftoverOpenTextMatcher, questionAfterAbortMatcher];
84
+ }
85
+ describe('question text before dropdown after abort retry', () => {
86
+ const ctx = setupQueueAdvancedSuite({
87
+ channelId: TEXT_CHANNEL_ID,
88
+ channelName: 'qa-question-abort-retry-order',
89
+ dirName: 'qa-question-abort-retry-order',
90
+ username: 'question-abort-retry-tester',
91
+ extraMatchers: createAbortRetryQuestionMatchers(),
92
+ });
93
+ test('posts leftover aborted text before the question dropdown', async () => {
94
+ const leftoverMarker = 'ABORT_RETRY_LEFTOVER_TEXT_MARKER';
95
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
96
+ content: leftoverMarker,
97
+ });
98
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
99
+ timeout: 8_000,
100
+ predicate: (t) => {
101
+ return t.name === leftoverMarker;
102
+ },
103
+ });
104
+ const th = ctx.discord.thread(thread.id);
105
+ await waitForBotMessageContaining({
106
+ discord: ctx.discord,
107
+ threadId: thread.id,
108
+ text: 'abort-retry-started',
109
+ timeout: 8_000,
110
+ });
111
+ const runtime = getRuntime(thread.id);
112
+ expect(runtime).toBeDefined();
113
+ if (!runtime) {
114
+ throw new Error('Expected runtime for abort-retry question test');
115
+ }
116
+ runtime.abortActiveRun('test-question-abort-retry-flush');
117
+ await th.user(TEST_USER_ID).sendMessage({
118
+ content: 'QUESTION_AFTER_ABORT_RETRY_MARKER',
119
+ });
120
+ await waitForBotMessageContaining({
121
+ discord: ctx.discord,
122
+ threadId: thread.id,
123
+ text: 'What next after abort?',
124
+ timeout: 8_000,
125
+ });
126
+ expect(await th.text()).toMatchInlineSnapshot(`
127
+ "--- from: user (question-abort-retry-tester)
128
+ ABORT_RETRY_LEFTOVER_TEXT_MARKER
129
+ --- from: assistant (TestBot)
130
+ > *using deterministic-provider/deterministic-v2*
131
+ > abort-retry-started
132
+ --- from: user (question-abort-retry-tester)
133
+ QUESTION_AFTER_ABORT_RETRY_MARKER
134
+ --- from: assistant (TestBot)
135
+ > LEFTOVER_ABORTED_TEXT
136
+ PLAN_AFTER_ABORT_RETRY
137
+ **Next step**
138
+ What next after abort?"
139
+ `);
140
+ const messages = await th.getMessages();
141
+ const leftoverIndex = messages.findIndex((message) => {
142
+ return getMessageVisibleText(message).includes('LEFTOVER_ABORTED_TEXT');
143
+ });
144
+ const questionIndex = messages.findIndex((message) => {
145
+ return getMessageVisibleText(message).includes('What next after abort?');
146
+ });
147
+ expect(leftoverIndex).toBeGreaterThanOrEqual(0);
148
+ expect(questionIndex).toBeGreaterThan(leftoverIndex);
149
+ }, 15_000);
150
+ });
@@ -0,0 +1,52 @@
1
+ // E2e: text that precedes a question tool must post before the dropdown.
2
+ // OpenCode can emit question.asked before the text part's time.end; Kimaki
3
+ // must wait so the plan is not hidden under the question UI.
4
+ import { describe, test, expect } from 'vitest';
5
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
6
+ import { getMessageVisibleText, waitForBotMessageContaining } from './test-utils.js';
7
+ const TEXT_CHANNEL_ID = '200000000000001042';
8
+ describe('question text before dropdown', () => {
9
+ const ctx = setupQueueAdvancedSuite({
10
+ channelId: TEXT_CHANNEL_ID,
11
+ channelName: 'qa-question-text-order',
12
+ dirName: 'qa-question-text-order',
13
+ username: 'question-text-tester',
14
+ });
15
+ test('posts plan text before the question dropdown', async () => {
16
+ const marker = 'QUESTION_AFTER_TEXT_MARKER';
17
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
18
+ content: marker,
19
+ });
20
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
21
+ timeout: 8_000,
22
+ predicate: (t) => {
23
+ return t.name === marker;
24
+ },
25
+ });
26
+ const th = ctx.discord.thread(thread.id);
27
+ await waitForBotMessageContaining({
28
+ discord: ctx.discord,
29
+ threadId: thread.id,
30
+ text: 'What next?',
31
+ timeout: 8_000,
32
+ });
33
+ expect(await th.text()).toMatchInlineSnapshot(`
34
+ "--- from: user (question-text-tester)
35
+ QUESTION_AFTER_TEXT_MARKER
36
+ --- from: assistant (TestBot)
37
+ > *using deterministic-provider/deterministic-v2*
38
+ PLAN_TEXT_BEFORE_QUESTION
39
+ **Next step**
40
+ What next?"
41
+ `);
42
+ const messages = await th.getMessages();
43
+ const planIndex = messages.findIndex((message) => {
44
+ return getMessageVisibleText(message).includes('PLAN_TEXT_BEFORE_QUESTION');
45
+ });
46
+ const questionIndex = messages.findIndex((message) => {
47
+ return getMessageVisibleText(message).includes('What next?');
48
+ });
49
+ expect(planIndex).toBeGreaterThanOrEqual(0);
50
+ expect(questionIndex).toBeGreaterThan(planIndex);
51
+ }, 15_000);
52
+ });
@@ -5,7 +5,7 @@ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-set
5
5
  import { getRuntime, } from './session-handler/thread-session-runtime.js';
6
6
  import { getThreadState } from './session-handler/thread-runtime-state.js';
7
7
  import { setSessionModel } from './database.js';
8
- import { waitForFooterMessage, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
8
+ import { isFooterMessage, waitForFooterMessage, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
9
9
  const TEXT_CHANNEL_ID = '200000000000001003';
10
10
  const e2eTest = describe;
11
11
  e2eTest('queue advanced: abort and retry', () => {
@@ -83,7 +83,7 @@ e2eTest('queue advanced: abort and retry', () => {
83
83
  const timeline = await th.text();
84
84
  expect(timeline).toContain('Reply with exactly: oscar');
85
85
  expect(timeline).toContain('PLUGIN_TIMEOUT_SLEEP_MARKER');
86
- expect(timeline).toContain('⬥ starting sleep 100');
86
+ expect(timeline).toContain('starting sleep 100');
87
87
  expect(timeline).toContain('Reply with exactly: papa');
88
88
  expect(timeline).toContain('*project ⋅ main ⋅');
89
89
  // oscar comes before the sleep marker, sleep before papa
@@ -145,9 +145,7 @@ e2eTest('queue advanced: abort and retry', () => {
145
145
  const msgs = await th.getMessages();
146
146
  const newMsgs = msgs.slice(baselineCount);
147
147
  const hasFooter = newMsgs.some((m) => {
148
- return m.author.id === ctx.discord.botUserId
149
- && m.content.startsWith('*')
150
- && m.content.includes('⋅');
148
+ return isFooterMessage({ message: m, botUserId: ctx.discord.botUserId });
151
149
  });
152
150
  expect(hasFooter).toBe(false);
153
151
  }
@@ -155,9 +153,9 @@ e2eTest('queue advanced: abort and retry', () => {
155
153
  "--- from: user (queue-advanced-tester)
156
154
  Reply with exactly: abort-no-footer-setup
157
155
  --- from: assistant (TestBot)
158
- *using deterministic-provider/deterministic-v2*
159
- ⬥ ok
160
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
156
+ > *using deterministic-provider/deterministic-v2*
157
+ > ok
158
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
161
159
  --- from: user (queue-advanced-tester)
162
160
  SLOW_ABORT_MARKER run long response"
163
161
  `);
@@ -256,7 +254,7 @@ e2eTest('queue advanced: abort and retry', () => {
256
254
  const text = await th.text();
257
255
  // The follow-up reply ("ok") must be present with deterministic-v3
258
256
  expect(text).toContain('Reply with exactly: model-switch-followup');
259
- expect(text).toContain('⬥ ok');
257
+ expect(text).toContain('ok');
260
258
  // The old sleep text should be visible from the first turn
261
259
  expect(text).toContain('starting sleep 100');
262
260
  }, 10_000);
@@ -286,7 +284,7 @@ e2eTest('queue advanced: abort and retry', () => {
286
284
  "--- from: user (queue-advanced-tester)
287
285
  Reply with exactly: force-abort-setup
288
286
  --- from: assistant (TestBot)
289
- *using deterministic-provider/deterministic-v2*
287
+ > *using deterministic-provider/deterministic-v2*
290
288
  --- from: user (queue-advanced-tester)
291
289
  SLOW_ABORT_MARKER run long response"
292
290
  `);
@@ -122,15 +122,15 @@ describe('queue advanced: action buttons', () => {
122
122
  "--- from: user (queue-action-tester)
123
123
  Reply with exactly: action-button-setup
124
124
  --- from: assistant (TestBot)
125
- *using deterministic-provider/deterministic-v2*
126
- ⬥ ok
127
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
125
+ > *using deterministic-provider/deterministic-v2*
126
+ > ok
127
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
128
128
  **Action Required**
129
129
  _Selected: Continue action-buttons flow_
130
130
  [user clicks button]
131
131
  » **queue-action-tester:** Continue action-buttons flow
132
- ⬥ action-buttons-click-continued
133
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
132
+ > action-buttons-click-continued
133
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
134
134
  `);
135
135
  expect(timeline).toContain('action-buttons-click-continued');
136
136
  }, 20_000);
@@ -195,9 +195,9 @@ describe('queue advanced: action buttons', () => {
195
195
  "--- from: user (queue-action-tester)
196
196
  Reply with exactly: action-button-dismiss-setup
197
197
  --- from: assistant (TestBot)
198
- *using deterministic-provider/deterministic-v2*
199
- ⬥ ok
200
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
198
+ > *using deterministic-provider/deterministic-v2*
199
+ > ok
200
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
201
201
  **Action Required**
202
202
  _Buttons dismissed._
203
203
  --- from: user (queue-action-tester)
@@ -78,6 +78,31 @@ export function createDeterministicMatchers() {
78
78
  partDelaysMs: [0, 500, 0, 0, 0],
79
79
  },
80
80
  };
81
+ const questionSelectDrainMatcher = {
82
+ id: 'question-select-drain-first',
83
+ priority: 109,
84
+ when: {
85
+ latestUserTextIncludes: 'QUESTION_SELECT_DRAIN_FIRST_MARKER',
86
+ },
87
+ then: {
88
+ parts: [
89
+ { type: 'stream-start', warnings: [] },
90
+ { type: 'text-start', id: 'question-select-drain-first' },
91
+ {
92
+ type: 'text-delta',
93
+ id: 'question-select-drain-first',
94
+ delta: 'question-drain-first',
95
+ },
96
+ { type: 'text-end', id: 'question-select-drain-first' },
97
+ {
98
+ type: 'finish',
99
+ finishReason: 'stop',
100
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
101
+ },
102
+ ],
103
+ partDelaysMs: [0, 0, 0, 400, 0],
104
+ },
105
+ };
81
106
  const slowAbortMatcher = {
82
107
  id: 'slow-abort-marker',
83
108
  priority: 100,
@@ -260,9 +285,7 @@ export function createDeterministicMatchers() {
260
285
  usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
261
286
  },
262
287
  ],
263
- // Keep run busy long enough after permission reply so typing keepalive
264
- // must pulse again. This makes typing resume assertions deterministic.
265
- partDelaysMs: [0, 0, 0, 0, 8_000],
288
+ partDelaysMs: [0, 400, 0, 0, 0],
266
289
  },
267
290
  };
268
291
  const externalDirectoryProbeMatcher = {
@@ -382,6 +405,7 @@ export function createDeterministicMatchers() {
382
405
  when: {
383
406
  lastMessageRole: 'user',
384
407
  latestUserTextIncludes: 'QUESTION_TEXT_ANSWER_MARKER',
408
+ rawPromptRegex: '^(?!.*question-text-answer-call)',
385
409
  },
386
410
  then: {
387
411
  parts: [
@@ -417,6 +441,7 @@ export function createDeterministicMatchers() {
417
441
  when: {
418
442
  lastMessageRole: 'user',
419
443
  latestUserTextIncludes: 'QUESTION_SELECT_QUEUE_MARKER',
444
+ rawPromptRegex: '^(?!.*question-select-queue-call)',
420
445
  },
421
446
  then: {
422
447
  parts: [
@@ -444,6 +469,49 @@ export function createDeterministicMatchers() {
444
469
  ],
445
470
  },
446
471
  };
472
+ // Text then question, with text-end delayed after the tool call.
473
+ // Reproduces OpenCode emitting question.asked before the text part's time.end.
474
+ const questionAfterTextMatcher = {
475
+ id: 'question-after-text-marker',
476
+ priority: 108,
477
+ when: {
478
+ lastMessageRole: 'user',
479
+ latestUserTextIncludes: 'QUESTION_AFTER_TEXT_MARKER',
480
+ },
481
+ then: {
482
+ parts: [
483
+ { type: 'stream-start', warnings: [] },
484
+ { type: 'text-start', id: 'plan-before-question' },
485
+ {
486
+ type: 'text-delta',
487
+ id: 'plan-before-question',
488
+ delta: 'PLAN_TEXT_BEFORE_QUESTION',
489
+ },
490
+ {
491
+ type: 'tool-call',
492
+ toolCallId: 'question-after-text-call',
493
+ toolName: 'question',
494
+ input: JSON.stringify({
495
+ questions: [{
496
+ question: 'What next?',
497
+ header: 'Next step',
498
+ options: [
499
+ { label: 'Commit', description: 'Commit these files' },
500
+ { label: 'Stop', description: 'Leave uncommitted' },
501
+ ],
502
+ }],
503
+ }),
504
+ },
505
+ { type: 'text-end', id: 'plan-before-question' },
506
+ {
507
+ type: 'finish',
508
+ finishReason: 'tool-calls',
509
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
510
+ },
511
+ ],
512
+ partDelaysMs: [200, 0, 0, 0, 400, 0],
513
+ },
514
+ };
447
515
  // Model responds with text + tool call, then after tool result the
448
516
  // follow-up matcher responds with text. This creates two assistant messages:
449
517
  // first with finish="tool-calls" + completed, second with finish="stop".
@@ -753,6 +821,7 @@ export function createDeterministicMatchers() {
753
821
  },
754
822
  };
755
823
  return [
824
+ questionSelectDrainMatcher,
756
825
  slowAbortMatcher,
757
826
  slowBusyMatcher,
758
827
  typingRepulseMatcher,
@@ -760,6 +829,7 @@ export function createDeterministicMatchers() {
760
829
  actionButtonClickFollowupMatcher,
761
830
  questionToolMatcher,
762
831
  questionSelectQueueMatcher,
832
+ questionAfterTextMatcher,
763
833
  permissionTypingMatcher,
764
834
  permissionTypingFollowupMatcher,
765
835
  externalDirectoryProbeMatcher,
@@ -787,7 +857,7 @@ export const TEST_USER_ID = '200000000000000991';
787
857
  * Each caller should use a unique channelId and dirName to avoid collisions
788
858
  * when vitest runs files in parallel.
789
859
  */
790
- export function setupQueueAdvancedSuite({ channelId, channelName, dirName, username, restrictExternalDirectories = false, projectPermission, }) {
860
+ export function setupQueueAdvancedSuite({ channelId, channelName, dirName, username, restrictExternalDirectories = false, projectPermission, extraMatchers = [], }) {
791
861
  const ctx = {
792
862
  directories: undefined,
793
863
  discord: undefined,
@@ -828,7 +898,10 @@ export function setupQueueAdvancedSuite({ channelId, channelName, dirName, usern
828
898
  providerNpm,
829
899
  model: 'deterministic-v2',
830
900
  smallModel: 'deterministic-v3',
831
- settings: { strict: false, matchers: createDeterministicMatchers() },
901
+ settings: {
902
+ strict: false,
903
+ matchers: [...createDeterministicMatchers(), ...extraMatchers],
904
+ },
832
905
  });
833
906
  fs.writeFileSync(path.join(ctx.directories.projectDirectory, 'opencode.json'), JSON.stringify(projectPermission
834
907
  ? { ...opencodeConfig, permission: projectPermission }