kimaki 0.28.0 → 0.30.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 (179) hide show
  1. package/dist/agent-model.e2e.test.js +33 -33
  2. package/dist/anthropic-account-identity.js +1 -1
  3. package/dist/anthropic-auth-plugin.js +5 -5
  4. package/dist/anthropic-auth-plugin.test.js +30 -2
  5. package/dist/anthropic-auth-state.js +1 -1
  6. package/dist/bash-tool-schema-plugin.js +18 -0
  7. package/dist/bash-tool-schema-plugin.test.js +20 -1
  8. package/dist/bin.js +7 -3
  9. package/dist/bot-lifecycle.test.js +206 -0
  10. package/dist/cache-drift-plugin.js +25 -18
  11. package/dist/cache-rewrite.js +39 -0
  12. package/dist/cache-rewrite.test.js +41 -0
  13. package/dist/channel-management.js +113 -51
  14. package/dist/cli-commands/misc.js +4 -1
  15. package/dist/cli-commands/project.js +7 -4
  16. package/dist/cli-commands/session.js +118 -36
  17. package/dist/cli-commands/thread.js +63 -0
  18. package/dist/cli-parsing.test.js +3 -3
  19. package/dist/cli-runner.js +11 -1
  20. package/dist/cli-send-thread.e2e.test.js +2 -2
  21. package/dist/cli.js +6 -4
  22. package/dist/commands/ask-question.js +72 -23
  23. package/dist/commands/queue.js +84 -36
  24. package/dist/commands/queue.test.js +15 -0
  25. package/dist/commands/restart-opencode-server.js +7 -1
  26. package/dist/commands/session-id.js +5 -5
  27. package/dist/database.js +149 -6
  28. package/dist/db.js +34 -0
  29. package/dist/db.test.js +199 -1
  30. package/dist/default-channel-provisioning.test.js +2 -1
  31. package/dist/discord-bot.js +220 -146
  32. package/dist/discord-command-registration.js +1 -1
  33. package/dist/external-directory-permissions.e2e.test.js +3 -3
  34. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  35. package/dist/forum-sync/watchers.js +15 -0
  36. package/dist/gateway-proxy.e2e.test.js +9 -9
  37. package/dist/guild-categories.test.js +225 -0
  38. package/dist/hrana-server.js +68 -14
  39. package/dist/interaction-handler.js +5 -1
  40. package/dist/kimaki-opencode-plugin.js +2 -0
  41. package/dist/kimaki-opencode-plugin.test.js +28 -0
  42. package/dist/list-channel-threads.js +163 -0
  43. package/dist/list-channel-threads.test.js +80 -0
  44. package/dist/markdown.js +164 -102
  45. package/dist/markdown.test.js +88 -20
  46. package/dist/message-formatting.js +29 -18
  47. package/dist/message-formatting.test.js +111 -3
  48. package/dist/opencode.js +27 -12
  49. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  50. package/dist/question-abort-resume.e2e.test.js +100 -0
  51. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  52. package/dist/question-text-before-dropdown.e2e.test.js +1 -1
  53. package/dist/queue-advanced-abort.e2e.test.js +3 -3
  54. package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
  55. package/dist/queue-advanced-e2e-setup.js +63 -0
  56. package/dist/queue-advanced-footer.e2e.test.js +18 -18
  57. package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
  58. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  59. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  60. package/dist/queue-delete-message.e2e.test.js +4 -4
  61. package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
  62. package/dist/queue-interrupt-drain.e2e.test.js +10 -10
  63. package/dist/queue-question-select-drain.e2e.test.js +5 -5
  64. package/dist/runtime-lifecycle.e2e.test.js +95 -11
  65. package/dist/schema.js +21 -0
  66. package/dist/session-handler/event-stream-state.js +240 -6
  67. package/dist/session-handler/event-stream-state.test.js +1116 -130
  68. package/dist/session-handler/thread-runtime-state.js +4 -0
  69. package/dist/session-handler/thread-session-runtime.js +499 -89
  70. package/dist/sleep.e2e.test.js +7 -7
  71. package/dist/store.js +1 -1
  72. package/dist/system-message.js +64 -23
  73. package/dist/system-message.test.js +69 -23
  74. package/dist/task-id-plugin.js +16 -0
  75. package/dist/task-runner.js +7 -0
  76. package/dist/task-schedule.test.js +1 -1
  77. package/dist/test-utils.js +5 -3
  78. package/dist/thread-message-queue.e2e.test.js +30 -31
  79. package/dist/undo-redo.e2e.test.js +3 -3
  80. package/dist/voice-message.e2e.test.js +64 -26
  81. package/dist/voice-routing-input.e2e.test.js +4 -4
  82. package/dist/voice-routing-question.e2e.test.js +4 -4
  83. package/dist/voice.js +107 -21
  84. package/dist/voice.test.js +52 -1
  85. package/dist/wait-session.js +24 -2
  86. package/dist/worktree-lifecycle.e2e.test.js +8 -8
  87. package/package.json +7 -7
  88. package/skills/egaki/SKILL.md +14 -0
  89. package/skills/holocron/SKILL.md +125 -42
  90. package/skills/opensrc/SKILL.md +20 -3
  91. package/skills/spiceflow/SKILL.md +53 -172
  92. package/skills/strada/SKILL.md +61 -232
  93. package/src/agent-model.e2e.test.ts +33 -33
  94. package/src/anthropic-account-identity.ts +1 -1
  95. package/src/anthropic-auth-plugin.test.ts +37 -2
  96. package/src/anthropic-auth-plugin.ts +9 -7
  97. package/src/anthropic-auth-state.ts +1 -0
  98. package/src/bash-tool-schema-plugin.test.ts +29 -0
  99. package/src/bash-tool-schema-plugin.ts +31 -0
  100. package/src/bin.ts +7 -3
  101. package/src/bot-lifecycle.test.ts +252 -0
  102. package/src/cache-drift-plugin.ts +34 -24
  103. package/src/cache-rewrite.test.ts +49 -0
  104. package/src/cache-rewrite.ts +96 -0
  105. package/src/channel-management.ts +144 -65
  106. package/src/cli-commands/misc.ts +4 -1
  107. package/src/cli-commands/project.ts +9 -4
  108. package/src/cli-commands/session.ts +167 -38
  109. package/src/cli-commands/thread.ts +86 -0
  110. package/src/cli-parsing.test.ts +3 -3
  111. package/src/cli-runner.ts +11 -0
  112. package/src/cli-send-thread.e2e.test.ts +2 -2
  113. package/src/cli.ts +8 -6
  114. package/src/commands/ask-question.ts +88 -25
  115. package/src/commands/queue.test.ts +19 -0
  116. package/src/commands/queue.ts +111 -45
  117. package/src/commands/restart-opencode-server.ts +12 -1
  118. package/src/commands/session-id.ts +5 -5
  119. package/src/database.ts +219 -6
  120. package/src/db.test.ts +236 -0
  121. package/src/db.ts +38 -0
  122. package/src/default-channel-provisioning.test.ts +2 -1
  123. package/src/discord-bot.ts +272 -185
  124. package/src/discord-command-registration.ts +3 -1
  125. package/src/external-directory-permissions.e2e.test.ts +3 -3
  126. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  127. package/src/forum-sync/watchers.ts +20 -0
  128. package/src/gateway-proxy.e2e.test.ts +9 -9
  129. package/src/guild-categories.test.ts +260 -0
  130. package/src/hrana-server.ts +96 -16
  131. package/src/interaction-handler.ts +7 -0
  132. package/src/kimaki-opencode-plugin.test.ts +34 -0
  133. package/src/kimaki-opencode-plugin.ts +2 -0
  134. package/src/list-channel-threads.test.ts +90 -0
  135. package/src/list-channel-threads.ts +252 -0
  136. package/src/markdown.test.ts +108 -22
  137. package/src/markdown.ts +216 -103
  138. package/src/message-formatting.test.ts +129 -2
  139. package/src/message-formatting.ts +28 -14
  140. package/src/opencode.ts +28 -13
  141. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  142. package/src/question-abort-resume.e2e.test.ts +121 -0
  143. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  144. package/src/question-text-before-dropdown.e2e.test.ts +1 -1
  145. package/src/queue-advanced-abort.e2e.test.ts +3 -3
  146. package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
  147. package/src/queue-advanced-e2e-setup.ts +65 -0
  148. package/src/queue-advanced-footer.e2e.test.ts +18 -18
  149. package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
  150. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  151. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  152. package/src/queue-delete-message.e2e.test.ts +4 -4
  153. package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
  154. package/src/queue-interrupt-drain.e2e.test.ts +10 -10
  155. package/src/queue-question-select-drain.e2e.test.ts +5 -5
  156. package/src/runtime-lifecycle.e2e.test.ts +104 -10
  157. package/src/schema.sql +16 -0
  158. package/src/schema.ts +23 -0
  159. package/src/session-handler/event-stream-state.test.ts +1184 -133
  160. package/src/session-handler/event-stream-state.ts +322 -8
  161. package/src/session-handler/thread-runtime-state.ts +5 -0
  162. package/src/session-handler/thread-session-runtime.ts +597 -95
  163. package/src/sleep.e2e.test.ts +7 -7
  164. package/src/store.ts +2 -2
  165. package/src/system-message.test.ts +70 -23
  166. package/src/system-message.ts +67 -22
  167. package/src/task-id-plugin.ts +17 -0
  168. package/src/task-runner.ts +8 -0
  169. package/src/task-schedule.test.ts +1 -1
  170. package/src/test-utils.ts +5 -3
  171. package/src/thread-message-queue.e2e.test.ts +30 -31
  172. package/src/undo-redo.e2e.test.ts +3 -3
  173. package/src/voice-message.e2e.test.ts +70 -26
  174. package/src/voice-routing-input.e2e.test.ts +4 -4
  175. package/src/voice-routing-question.e2e.test.ts +4 -4
  176. package/src/voice.test.ts +56 -0
  177. package/src/voice.ts +135 -25
  178. package/src/wait-session.ts +25 -2
  179. package/src/worktree-lifecycle.e2e.test.ts +8 -8
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { afterEach, describe, test, expect } from 'vitest';
4
- import { asDiscordQuote, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
4
+ import { asDiscordQuote, asSystemLine, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, quotedTextFitsOneDiscordMessage, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
5
5
  import { getDataDir } from './config.js';
6
6
  describe('formatPart', () => {
7
7
  test('callout text is returned without a diamond prefix', () => {
@@ -42,6 +42,39 @@ describe('formatPart', () => {
42
42
  Done."
43
43
  `);
44
44
  });
45
+ test('synthetic user context stays hidden', () => {
46
+ const part = {
47
+ id: 'prt_branch',
48
+ type: 'text',
49
+ sessionID: 'ses_test',
50
+ messageID: 'msg_user',
51
+ text: '\n[current git branch is main]\n',
52
+ synthetic: true,
53
+ };
54
+ expect(formatPart(part)).toBe('');
55
+ });
56
+ test('ignored plugin notices stay visible', () => {
57
+ const part = {
58
+ id: 'prt_notice',
59
+ type: 'text',
60
+ sessionID: 'ses_test',
61
+ messageID: 'msg_notice',
62
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
63
+ ignored: true,
64
+ };
65
+ expect(formatPart(part)).toBe('Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.');
66
+ });
67
+ });
68
+ describe('asSystemLine', () => {
69
+ test('prefixes every line', () => {
70
+ expect(asSystemLine('hello\nworld')).toMatchInlineSnapshot(`
71
+ "-# hello
72
+ -# world"
73
+ `);
74
+ });
75
+ test('does not double-prefix', () => {
76
+ expect(asSystemLine('-# already marked')).toMatchInlineSnapshot(`"-# already marked"`);
77
+ });
45
78
  });
46
79
  describe('asDiscordQuote', () => {
47
80
  test('prefixes every line', () => {
@@ -61,6 +94,14 @@ describe('asDiscordQuote', () => {
61
94
  `);
62
95
  });
63
96
  });
97
+ describe('quotedTextFitsOneDiscordMessage', () => {
98
+ test('accepts a short line', () => {
99
+ expect(quotedTextFitsOneDiscordMessage('done')).toBe(true);
100
+ });
101
+ test('rejects text whose quote would split across Discord messages', () => {
102
+ expect(quotedTextFitsOneDiscordMessage('x'.repeat(1999))).toBe(false);
103
+ });
104
+ });
64
105
  describe('planAssistantTurnFlush', () => {
65
106
  function text(id, body, ended = true) {
66
107
  return { id, type: 'text', text: body, time: ended ? { end: 1 } : undefined };
@@ -144,7 +185,7 @@ describe('planAssistantTurnFlush', () => {
144
185
  }
145
186
  `);
146
187
  });
147
- test('progress holds unfinished last text and following tools', () => {
188
+ test('progress sends tools immediately while unfinished text is held', () => {
148
189
  expect(plan([text('t1', 'status', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
149
190
  {
150
191
  "hold": [
@@ -152,15 +193,51 @@ describe('planAssistantTurnFlush', () => {
152
193
  "id": "t1",
153
194
  "quoteText": false,
154
195
  },
196
+ ],
197
+ "send": [
155
198
  {
156
199
  "id": "tool1",
157
200
  "quoteText": false,
158
201
  },
159
202
  ],
203
+ }
204
+ `);
205
+ });
206
+ test('progress holds unfinished text only when nothing else is ready', () => {
207
+ expect(plan([text('t1', 'status', false)], 'progress')).toMatchInlineSnapshot(`
208
+ {
209
+ "hold": [
210
+ {
211
+ "id": "t1",
212
+ "quoteText": false,
213
+ },
214
+ ],
160
215
  "send": [],
161
216
  }
162
217
  `);
163
218
  });
219
+ test('progress sends completed text and later tools while later text is still open', () => {
220
+ expect(plan([text('t1', 'first'), text('t2', 'second', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
221
+ {
222
+ "hold": [
223
+ {
224
+ "id": "t2",
225
+ "quoteText": false,
226
+ },
227
+ ],
228
+ "send": [
229
+ {
230
+ "id": "t1",
231
+ "quoteText": true,
232
+ },
233
+ {
234
+ "id": "tool1",
235
+ "quoteText": false,
236
+ },
237
+ ],
238
+ }
239
+ `);
240
+ });
164
241
  test('progress holds unfinished last text until it ends', () => {
165
242
  expect(plan([text('t1', 'first'), text('t2', 'second', false)], 'progress')).toMatchInlineSnapshot(`
166
243
  {
@@ -284,6 +361,17 @@ describe('planAssistantTurnFlush', () => {
284
361
  test('progress still quotes two-line earlier text', () => {
285
362
  expect(plan([text('t1', 'line one\nline two'), tool('tool1'), text('t2', 'answer')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: true });
286
363
  });
364
+ test('progress keeps sleep-tool quote context after that tool is already sent', () => {
365
+ const parts = [text('t1', 'wait'), tool('sleep1', 'kimaki_sleep')];
366
+ expect(plan(parts, 'progress').send.find((entry) => entry.id === 't1')).toEqual({
367
+ id: 't1',
368
+ quoteText: false,
369
+ });
370
+ expect(plan([text('t1', 'wait')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({
371
+ id: 't1',
372
+ quoteText: true,
373
+ });
374
+ });
287
375
  test('progress does not quote earlier text before an interactive tool', () => {
288
376
  expect(plan([text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
289
377
  expect(plan([text('t1', 'wait'), tool('sleep1', 'kimaki_sleep'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
@@ -318,15 +406,35 @@ describe('sessionPartContent', () => {
318
406
  });
319
407
  });
320
408
  describe('collectSessionChunks', () => {
321
- function textPart({ id, text, messageID, }) {
409
+ function textPart({ id, text, messageID, synthetic, }) {
322
410
  return {
323
411
  id,
324
412
  type: 'text',
325
413
  sessionID: 'ses_test',
326
414
  messageID,
327
415
  text,
416
+ synthetic,
328
417
  };
329
418
  }
419
+ test('skips synthetic text parts', () => {
420
+ const { chunks } = collectSessionChunks({
421
+ messages: [
422
+ {
423
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
424
+ parts: [
425
+ textPart({
426
+ id: 'branch',
427
+ text: '\n[current git branch is main]\n',
428
+ messageID: 'msg_1',
429
+ synthetic: true,
430
+ }),
431
+ textPart({ id: 't1', text: 'working', messageID: 'msg_1' }),
432
+ ],
433
+ },
434
+ ],
435
+ });
436
+ expect(chunks.map((chunk) => chunk.content)).toEqual(['> working']);
437
+ });
330
438
  test('quotes short text throughout a turn', () => {
331
439
  const { chunks } = collectSessionChunks({
332
440
  messages: [
package/dist/opencode.js CHANGED
@@ -305,16 +305,21 @@ function ensureProcessCleanupHandlersRegistered() {
305
305
  killSingleServerProcessNow({ reason: 'process-exit' });
306
306
  killStartingServerProcessNow({ reason: 'process-exit' });
307
307
  });
308
- // Fallback for short-lived CLI subcommands that call process.exit without
309
- // running discord-bot.ts shutdown handlers.
310
- process.on('SIGINT', () => {
311
- killSingleServerProcessNow({ reason: 'sigint' });
312
- killStartingServerProcessNow({ reason: 'sigint' });
313
- });
314
- process.on('SIGTERM', () => {
315
- killSingleServerProcessNow({ reason: 'sigterm' });
316
- killStartingServerProcessNow({ reason: 'sigterm' });
317
- });
308
+ // Fallback for CLI subcommands without their own signal handling. Any signal
309
+ // listener disables Node's default exit, so if we are the only listener we
310
+ // must exit ourselves. Otherwise the process ignores Ctrl+C and, in the bot,
311
+ // keeps holding the hrana lock port. If another owner exists (the bot
312
+ // lifecycle handlers, a subcommand), it decides when to exit.
313
+ for (const signal of ['SIGINT', 'SIGTERM']) {
314
+ process.on(signal, () => {
315
+ killSingleServerProcessNow({ reason: signal.toLowerCase() });
316
+ killStartingServerProcessNow({ reason: signal.toLowerCase() });
317
+ if (process.listenerCount(signal) > 1) {
318
+ return;
319
+ }
320
+ process.exit(128 + os.constants.signals[signal]);
321
+ });
322
+ }
318
323
  }
319
324
  // ── Resolve opencode binary ──────────────────────────────────────
320
325
  // Resolve the full path to the opencode binary so we can spawn without
@@ -806,6 +811,15 @@ async function startSingleServer({ directory, } = {}) {
806
811
  return waitResult;
807
812
  }
808
813
  serverReady = true;
814
+ // stopOpencodeServer() may have run while we waited (bot shutdown). Never
815
+ // publish a server that nobody will stop.
816
+ if (global.shuttingDown || serverProcess.killed) {
817
+ killStartingServerProcessNow({ reason: 'stopped-during-startup' });
818
+ if (startingServerProcess === serverProcess) {
819
+ startingServerProcess = null;
820
+ }
821
+ return new ServerStartError({ port, reason: 'stopped during startup' });
822
+ }
809
823
  opencodeLogger.log(`Server ready on port ${port}`);
810
824
  // Always dump startup logs so plugin loading errors and other startup output
811
825
  // are visible in kimaki.log.
@@ -1143,6 +1157,9 @@ export function extractSdkErrorMessage(error) {
1143
1157
  * Used for process teardown, tests, and explicit restarts.
1144
1158
  */
1145
1159
  export async function stopOpencodeServer() {
1160
+ // A server still booting lives only in startingServerProcess.
1161
+ killStartingServerProcessNow({ reason: 'stop-opencode-server' });
1162
+ startingServerProcess = null;
1146
1163
  if (!singleServer) {
1147
1164
  return false;
1148
1165
  }
@@ -1168,8 +1185,6 @@ export async function stopOpencodeServer() {
1168
1185
  opencodeLogger.warn(killResult.message);
1169
1186
  }
1170
1187
  }
1171
- killStartingServerProcessNow({ reason: 'stop-opencode-server' });
1172
- startingServerProcess = null;
1173
1188
  singleServer = null;
1174
1189
  clientCache.clear();
1175
1190
  serverRetryCount = 0;
@@ -78,7 +78,7 @@ describe('abort clears pending question dropdown', () => {
78
78
  "--- from: user (question-abort-tester)
79
79
  QUESTION_SELECT_QUEUE_MARKER abort-test
80
80
  --- from: assistant (TestBot)
81
- > *using deterministic-provider/deterministic-v2*
81
+ -# *using deterministic-provider/deterministic-v2*
82
82
  **Select action**
83
83
  How to proceed?
84
84
  [user selects dropdown: 0]
@@ -0,0 +1,100 @@
1
+ // E2e test: a question answered after its run was aborted elsewhere must not be
2
+ // lost. The original run is dead, so question.reply is a no-op; kimaki resumes
3
+ // the session by sending the answers back as a fresh prompt.
4
+ import { describe, test, expect } from 'vitest';
5
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
6
+ import { waitForBotMessageContaining } from './test-utils.js';
7
+ import { pendingQuestionContexts } from './commands/ask-question.js';
8
+ import { getOpencodeClient } from './opencode.js';
9
+ import { getThreadSession } from './database.js';
10
+ import { getRuntime } from './session-handler/thread-session-runtime.js';
11
+ const TEXT_CHANNEL_ID = '200000000000001032';
12
+ describe('question answered after external abort resumes session', () => {
13
+ const ctx = setupQueueAdvancedSuite({
14
+ channelId: TEXT_CHANNEL_ID,
15
+ channelName: 'qa-question-abort-resume',
16
+ dirName: 'qa-question-abort-resume',
17
+ username: 'question-resume-tester',
18
+ });
19
+ test('answering a dropdown after opencode abort resumes with the answers', async () => {
20
+ const marker = 'QUESTION_ABORT_RESUME_MARKER';
21
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
22
+ content: marker,
23
+ });
24
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
25
+ timeout: 8_000,
26
+ predicate: (t) => {
27
+ return t.name === marker;
28
+ },
29
+ });
30
+ const th = ctx.discord.thread(thread.id);
31
+ const questionMessages = await waitForBotMessageContaining({
32
+ discord: ctx.discord,
33
+ threadId: thread.id,
34
+ text: 'How to proceed?',
35
+ timeout: 12_000,
36
+ });
37
+ const questionMsg = questionMessages.find((message) => {
38
+ return message.content.includes('How to proceed?');
39
+ });
40
+ if (!questionMsg) {
41
+ throw new Error('Expected question message');
42
+ }
43
+ const pendingEntry = [...pendingQuestionContexts.entries()].find(([, context]) => {
44
+ return context.thread.id === thread.id;
45
+ });
46
+ if (!pendingEntry) {
47
+ throw new Error('Expected pending question context');
48
+ }
49
+ const contextHash = pendingEntry[0];
50
+ // Abort directly through opencode (simulating a different opencode client),
51
+ // so kimaki's dropdown context stays live but the question is gone in
52
+ // opencode. kimaki's own abort would clear the dropdown instead.
53
+ const sessionId = await getThreadSession(thread.id);
54
+ if (!sessionId) {
55
+ throw new Error('Expected session id');
56
+ }
57
+ const client = getOpencodeClient(ctx.directories.projectDirectory);
58
+ if (!client) {
59
+ throw new Error('Expected opencode client');
60
+ }
61
+ await client.session.abort({
62
+ sessionID: sessionId,
63
+ directory: ctx.directories.projectDirectory,
64
+ });
65
+ // Wait until kimaki sees the session go idle (mirrors a user answering
66
+ // after they aborted in another opencode).
67
+ const runtime = getRuntime(thread.id);
68
+ if (!runtime) {
69
+ throw new Error('Expected runtime');
70
+ }
71
+ const idleStart = Date.now();
72
+ while (runtime.isBusy() && Date.now() - idleStart < 8_000) {
73
+ await new Promise((resolve) => {
74
+ setTimeout(resolve, 50);
75
+ });
76
+ }
77
+ expect(runtime.isBusy()).toBe(false);
78
+ // Answer the dropdown. reply fails (question no longer pending), so kimaki
79
+ // resumes the session with the answers as a new prompt.
80
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
81
+ messageId: questionMsg.id,
82
+ customId: `ask_question:${contextHash}:0`,
83
+ values: ['0'],
84
+ });
85
+ await th.waitForInteractionAck({
86
+ interactionId: interaction.id,
87
+ timeout: 8_000,
88
+ });
89
+ await waitForBotMessageContaining({
90
+ discord: ctx.discord,
91
+ threadId: thread.id,
92
+ text: 'resumed-after-abort',
93
+ timeout: 12_000,
94
+ });
95
+ const timeline = await th.text({ showInteractions: true });
96
+ expect(timeline).toContain('How to proceed?');
97
+ expect(timeline).toContain('» **question-resume-tester:** Alpha');
98
+ expect(timeline).toContain('resumed-after-abort');
99
+ }, 25_000);
100
+ });
@@ -127,7 +127,7 @@ describe('question text before dropdown after abort retry', () => {
127
127
  "--- from: user (question-abort-retry-tester)
128
128
  ABORT_RETRY_LEFTOVER_TEXT_MARKER
129
129
  --- from: assistant (TestBot)
130
- > *using deterministic-provider/deterministic-v2*
130
+ -# *using deterministic-provider/deterministic-v2*
131
131
  > abort-retry-started
132
132
  --- from: user (question-abort-retry-tester)
133
133
  QUESTION_AFTER_ABORT_RETRY_MARKER
@@ -34,7 +34,7 @@ describe('question text before dropdown', () => {
34
34
  "--- from: user (question-text-tester)
35
35
  QUESTION_AFTER_TEXT_MARKER
36
36
  --- from: assistant (TestBot)
37
- > *using deterministic-provider/deterministic-v2*
37
+ -# *using deterministic-provider/deterministic-v2*
38
38
  PLAN_TEXT_BEFORE_QUESTION
39
39
  **Next step**
40
40
  What next?"
@@ -153,9 +153,9 @@ e2eTest('queue advanced: abort and retry', () => {
153
153
  "--- from: user (queue-advanced-tester)
154
154
  Reply with exactly: abort-no-footer-setup
155
155
  --- from: assistant (TestBot)
156
- > *using deterministic-provider/deterministic-v2*
156
+ -# *using deterministic-provider/deterministic-v2*
157
157
  > ok
158
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
158
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
159
159
  --- from: user (queue-advanced-tester)
160
160
  SLOW_ABORT_MARKER run long response"
161
161
  `);
@@ -284,7 +284,7 @@ e2eTest('queue advanced: abort and retry', () => {
284
284
  "--- from: user (queue-advanced-tester)
285
285
  Reply with exactly: force-abort-setup
286
286
  --- from: assistant (TestBot)
287
- > *using deterministic-provider/deterministic-v2*
287
+ -# *using deterministic-provider/deterministic-v2*
288
288
  --- from: user (queue-advanced-tester)
289
289
  SLOW_ABORT_MARKER run long response"
290
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*
125
+ -# *using deterministic-provider/deterministic-v2*
126
126
  > ok
127
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
127
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
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
132
  > action-buttons-click-continued
133
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
133
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
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*
198
+ -# *using deterministic-provider/deterministic-v2*
199
199
  > ok
200
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
200
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
201
201
  **Action Required**
202
202
  _Buttons dismissed._
203
203
  --- from: user (queue-action-tester)
@@ -820,8 +820,71 @@ export function createDeterministicMatchers() {
820
820
  ],
821
821
  },
822
822
  };
823
+ // Question tool for the abort-then-answer case: model asks a question via
824
+ // dropdown. The test aborts the run before answering (session goes idle),
825
+ // then answers via select. Because the original run is dead, kimaki resumes
826
+ // the session by sending the answers back as a new prompt.
827
+ const questionAbortResumeMatcher = {
828
+ id: 'question-abort-resume-marker',
829
+ priority: 107,
830
+ when: {
831
+ lastMessageRole: 'user',
832
+ latestUserTextIncludes: 'QUESTION_ABORT_RESUME_MARKER',
833
+ rawPromptRegex: '^(?!.*question-abort-resume-call)',
834
+ },
835
+ then: {
836
+ parts: [
837
+ { type: 'stream-start', warnings: [] },
838
+ {
839
+ type: 'tool-call',
840
+ toolCallId: 'question-abort-resume-call',
841
+ toolName: 'question',
842
+ input: JSON.stringify({
843
+ questions: [{
844
+ question: 'How to proceed?',
845
+ header: 'Select action',
846
+ options: [
847
+ { label: 'Alpha', description: 'Alpha option' },
848
+ { label: 'Beta', description: 'Beta option' },
849
+ ],
850
+ }],
851
+ }),
852
+ },
853
+ {
854
+ type: 'finish',
855
+ finishReason: 'tool-calls',
856
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
857
+ },
858
+ ],
859
+ },
860
+ };
861
+ // Follow-up after an aborted question is answered: kimaki resumes with a
862
+ // prompt containing the answers summary.
863
+ const questionAbortResumeFollowupMatcher = {
864
+ id: 'question-abort-resume-followup',
865
+ priority: 107,
866
+ when: {
867
+ lastMessageRole: 'user',
868
+ latestUserTextIncludes: 'Answers to your previous questions:',
869
+ },
870
+ then: {
871
+ parts: [
872
+ { type: 'stream-start', warnings: [] },
873
+ { type: 'text-start', id: 'resume-reply' },
874
+ { type: 'text-delta', id: 'resume-reply', delta: 'resumed-after-abort' },
875
+ { type: 'text-end', id: 'resume-reply' },
876
+ {
877
+ type: 'finish',
878
+ finishReason: 'stop',
879
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
880
+ },
881
+ ],
882
+ },
883
+ };
823
884
  return [
824
885
  questionSelectDrainMatcher,
886
+ questionAbortResumeMatcher,
887
+ questionAbortResumeFollowupMatcher,
825
888
  slowAbortMatcher,
826
889
  slowBusyMatcher,
827
890
  typingRepulseMatcher,
@@ -34,9 +34,9 @@ e2eTest('queue advanced: footer emission', () => {
34
34
  "--- from: user (queue-advanced-tester)
35
35
  Reply with exactly: footer-check
36
36
  --- from: assistant (TestBot)
37
- > *using deterministic-provider/deterministic-v2*
37
+ -# *using deterministic-provider/deterministic-v2*
38
38
  > ok
39
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
39
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
40
40
  `);
41
41
  const foundFooter = footerMessages.some((m) => {
42
42
  return m.author.id === ctx.discord.botUserId
@@ -44,16 +44,16 @@ e2eTest('queue advanced: footer emission', () => {
44
44
  });
45
45
  expect(foundFooter).toBe(true);
46
46
  }, 8_000);
47
- test('skip-footer-mentions omits the thread creator mention', async () => {
48
- store.setState({ footerMentionsEnabled: false });
47
+ test('enable-footer-mentions mentions the thread creator', async () => {
48
+ store.setState({ footerMentionsEnabled: true });
49
49
  try {
50
50
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
51
- content: 'Reply with exactly: footer-without-mention',
51
+ content: 'Reply with exactly: footer-with-mention',
52
52
  });
53
53
  const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
54
54
  timeout: 4_000,
55
55
  predicate: (t) => {
56
- return t.name === 'Reply with exactly: footer-without-mention';
56
+ return t.name === 'Reply with exactly: footer-with-mention';
57
57
  },
58
58
  });
59
59
  const th = ctx.discord.thread(thread.id);
@@ -65,15 +65,15 @@ e2eTest('queue advanced: footer emission', () => {
65
65
  });
66
66
  expect(await th.text()).toMatchInlineSnapshot(`
67
67
  "--- from: user (queue-advanced-tester)
68
- Reply with exactly: footer-without-mention
68
+ Reply with exactly: footer-with-mention
69
69
  --- from: assistant (TestBot)
70
- > *using deterministic-provider/deterministic-v2*
70
+ -# *using deterministic-provider/deterministic-v2*
71
71
  > ok
72
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2*"
72
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
73
73
  `);
74
74
  }
75
75
  finally {
76
- store.setState({ footerMentionsEnabled: true });
76
+ store.setState({ footerMentionsEnabled: false });
77
77
  }
78
78
  }, 8_000);
79
79
  test('footer appears after second message in same session', async () => {
@@ -121,14 +121,14 @@ e2eTest('queue advanced: footer emission', () => {
121
121
  "--- from: user (queue-advanced-tester)
122
122
  Reply with exactly: footer-multi-setup
123
123
  --- from: assistant (TestBot)
124
- > *using deterministic-provider/deterministic-v2*
124
+ -# *using deterministic-provider/deterministic-v2*
125
125
  > ok
126
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
126
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
127
127
  --- from: user (queue-advanced-tester)
128
128
  Reply with exactly: footer-multi-second
129
129
  --- from: assistant (TestBot)
130
130
  > ok
131
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
131
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
132
132
  `);
133
133
  if (footerCount >= 2) {
134
134
  expect(footerCount).toBeGreaterThanOrEqual(2);
@@ -216,9 +216,9 @@ e2eTest('queue advanced: footer emission', () => {
216
216
  "--- from: user (queue-advanced-tester)
217
217
  Reply with exactly: interrupt-footer-setup
218
218
  --- from: assistant (TestBot)
219
- > *using deterministic-provider/deterministic-v2*
219
+ -# *using deterministic-provider/deterministic-v2*
220
220
  > ok
221
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
221
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
222
222
  --- from: user (queue-advanced-tester)
223
223
  PLUGIN_TIMEOUT_SLEEP_MARKER
224
224
  --- from: assistant (TestBot)
@@ -227,7 +227,7 @@ e2eTest('queue advanced: footer emission', () => {
227
227
  Reply with exactly: interrupt-footer-followup
228
228
  --- from: assistant (TestBot)
229
229
  > ok
230
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
230
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
231
231
  `);
232
232
  expect(followupUserIdx).toBeGreaterThanOrEqual(0);
233
233
  expect(okReplyIdx).toBeGreaterThan(followupUserIdx);
@@ -274,13 +274,13 @@ e2eTest('queue advanced: footer emission', () => {
274
274
  "--- from: user (queue-advanced-tester)
275
275
  TOOL_CALL_FOOTER_MARKER
276
276
  --- from: assistant (TestBot)
277
- > *using deterministic-provider/deterministic-v2*
277
+ -# *using deterministic-provider/deterministic-v2*
278
278
  > running tool
279
279
 
280
280
  ┣ bash _echo tool-call-footer-test_
281
281
 
282
282
  > tool call completed
283
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
283
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
284
284
  `);
285
285
  }, 10_000);
286
286
  });
@@ -248,9 +248,9 @@ describe('queue advanced: /model with interrupt recovery', () => {
248
248
  "--- from: user (queue-model-switch-tester)
249
249
  Reply with exactly: model-switcher-setup
250
250
  --- from: assistant (TestBot)
251
- > *using deterministic-provider/deterministic-v2*
251
+ -# *using deterministic-provider/deterministic-v2*
252
252
  > ok
253
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>
253
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
254
254
  Model set for this session:
255
255
  **Deterministic Provider** / **deterministic-v3**
256
256
  \`deterministic-provider/deterministic-v3\`
@@ -259,13 +259,12 @@ describe('queue advanced: /model with interrupt recovery', () => {
259
259
  --- from: user (queue-model-switch-tester)
260
260
  PLUGIN_TIMEOUT_SLEEP_MARKER
261
261
  --- from: assistant (TestBot)
262
- > ok
263
262
  > starting sleep 100
264
263
  --- from: user (queue-model-switch-tester)
265
264
  Reply with exactly: model-switcher-followup
266
265
  --- from: assistant (TestBot)
267
266
  > ok
268
- > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v3* <@200000000000000991>"
267
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
269
268
  `);
270
269
  expect(footer).toBeDefined();
271
270
  expect(footer?.content).toContain('deterministic-v3');
@@ -89,7 +89,7 @@ describe('queue advanced: typing around permissions', () => {
89
89
  "--- from: user (queue-permission-tester)
90
90
  PERMISSION_TYPING_MARKER
91
91
  --- from: assistant (TestBot)
92
- > *using deterministic-provider/deterministic-v2*
92
+ -# *using deterministic-provider/deterministic-v2*
93
93
  > requesting external read permission
94
94
 
95
95
  ┣ read *.zprofile*
@@ -101,7 +101,7 @@ describe('queue advanced: typing around permissions', () => {
101
101
  [user clicks button]
102
102
 
103
103
  > permission-flow-done
104
- > *project ⋅ main ⋅ 2s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
104
+ -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
105
105
  `);
106
106
  const timeline = await th.text({
107
107
  showTyping: true,