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
@@ -124,13 +124,13 @@ describe('queue advanced: action buttons', () => {
124
124
  --- from: assistant (TestBot)
125
125
  *using deterministic-provider/deterministic-v2*
126
126
  ⬥ ok
127
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
127
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ 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
132
  ⬥ action-buttons-click-continued
133
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
133
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
134
134
  `);
135
135
  expect(timeline).toContain('action-buttons-click-continued');
136
136
  }, 20_000);
@@ -197,7 +197,7 @@ describe('queue advanced: action buttons', () => {
197
197
  --- from: assistant (TestBot)
198
198
  *using deterministic-provider/deterministic-v2*
199
199
  ⬥ ok
200
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
200
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ 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 }
@@ -2,6 +2,7 @@
2
2
  // Split from thread-queue-advanced.e2e.test.ts for parallelization.
3
3
  import { describe, test, expect } from 'vitest';
4
4
  import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
5
+ import { store } from './store.js';
5
6
  import { waitForFooterMessage, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
6
7
  const TEXT_CHANNEL_ID = '200000000000001001';
7
8
  const e2eTest = describe;
@@ -35,7 +36,7 @@ e2eTest('queue advanced: footer emission', () => {
35
36
  --- from: assistant (TestBot)
36
37
  *using deterministic-provider/deterministic-v2*
37
38
  ⬥ ok
38
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
39
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
39
40
  `);
40
41
  const foundFooter = footerMessages.some((m) => {
41
42
  return m.author.id === ctx.discord.botUserId
@@ -44,6 +45,38 @@ e2eTest('queue advanced: footer emission', () => {
44
45
  });
45
46
  expect(foundFooter).toBe(true);
46
47
  }, 8_000);
48
+ test('skip-footer-mentions omits the thread creator mention', async () => {
49
+ store.setState({ footerMentionsEnabled: false });
50
+ try {
51
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
52
+ content: 'Reply with exactly: footer-without-mention',
53
+ });
54
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
55
+ timeout: 4_000,
56
+ predicate: (t) => {
57
+ return t.name === 'Reply with exactly: footer-without-mention';
58
+ },
59
+ });
60
+ const th = ctx.discord.thread(thread.id);
61
+ await th.waitForBotReply({ timeout: 4_000 });
62
+ await waitForFooterMessage({
63
+ discord: ctx.discord,
64
+ threadId: thread.id,
65
+ timeout: 4_000,
66
+ });
67
+ expect(await th.text()).toMatchInlineSnapshot(`
68
+ "--- from: user (queue-advanced-tester)
69
+ Reply with exactly: footer-without-mention
70
+ --- from: assistant (TestBot)
71
+ *using deterministic-provider/deterministic-v2*
72
+ ⬥ ok
73
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
74
+ `);
75
+ }
76
+ finally {
77
+ store.setState({ footerMentionsEnabled: true });
78
+ }
79
+ }, 8_000);
47
80
  test('footer appears after second message in same session', async () => {
48
81
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
49
82
  content: 'Reply with exactly: footer-multi-setup',
@@ -92,12 +125,12 @@ e2eTest('queue advanced: footer emission', () => {
92
125
  --- from: assistant (TestBot)
93
126
  *using deterministic-provider/deterministic-v2*
94
127
  ⬥ ok
95
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
128
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
96
129
  --- from: user (queue-advanced-tester)
97
130
  Reply with exactly: footer-multi-second
98
131
  --- from: assistant (TestBot)
99
132
  ⬥ ok
100
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
133
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
101
134
  `);
102
135
  if (footerCount >= 2) {
103
136
  expect(footerCount).toBeGreaterThanOrEqual(2);
@@ -189,7 +222,7 @@ e2eTest('queue advanced: footer emission', () => {
189
222
  --- from: assistant (TestBot)
190
223
  *using deterministic-provider/deterministic-v2*
191
224
  ⬥ ok
192
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
225
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
193
226
  --- from: user (queue-advanced-tester)
194
227
  PLUGIN_TIMEOUT_SLEEP_MARKER
195
228
  --- from: assistant (TestBot)
@@ -198,7 +231,7 @@ e2eTest('queue advanced: footer emission', () => {
198
231
  Reply with exactly: interrupt-footer-followup
199
232
  --- from: assistant (TestBot)
200
233
  ⬥ ok
201
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
234
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
202
235
  `);
203
236
  expect(followupUserIdx).toBeGreaterThanOrEqual(0);
204
237
  expect(okReplyIdx).toBeGreaterThan(followupUserIdx);
@@ -212,102 +245,7 @@ e2eTest('queue advanced: footer emission', () => {
212
245
  });
213
246
  expect(footerBetween).toBe(false);
214
247
  }, 15_000);
215
- test('plugin timeout interrupt aborts slow sleep and avoids intermediate footer', async () => {
216
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
217
- content: 'Reply with exactly: plugin-timeout-setup',
218
- });
219
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
220
- timeout: 4_000,
221
- predicate: (t) => {
222
- return t.name === 'Reply with exactly: plugin-timeout-setup';
223
- },
224
- });
225
- const th = ctx.discord.thread(thread.id);
226
- await th.waitForBotReply({ timeout: 4_000 });
227
- await waitForBotMessageContaining({
228
- discord: ctx.discord,
229
- threadId: thread.id,
230
- userId: TEST_USER_ID,
231
- text: '*project',
232
- timeout: 4_000,
233
- });
234
- await th.user(TEST_USER_ID).sendMessage({
235
- content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
236
- });
237
- await waitForBotMessageContaining({
238
- discord: ctx.discord,
239
- threadId: thread.id,
240
- userId: TEST_USER_ID,
241
- text: 'starting sleep 100',
242
- afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
243
- timeout: 4_000,
244
- });
245
- await th.user(TEST_USER_ID).sendMessage({
246
- content: 'Reply with exactly: plugin-timeout-after',
247
- });
248
- const messages = await waitForBotMessageContaining({
249
- discord: ctx.discord,
250
- threadId: thread.id,
251
- userId: TEST_USER_ID,
252
- text: 'ok',
253
- afterUserMessageIncludes: 'plugin-timeout-after',
254
- timeout: 12_000,
255
- });
256
- const messagesWithFooter = await waitForFooterMessage({
257
- discord: ctx.discord,
258
- threadId: thread.id,
259
- timeout: 12_000,
260
- afterMessageIncludes: 'plugin-timeout-after',
261
- afterAuthorId: TEST_USER_ID,
262
- });
263
- const afterIndex = messagesWithFooter.findIndex((message) => {
264
- return (message.author.id === TEST_USER_ID
265
- && message.content.includes('plugin-timeout-after'));
266
- });
267
- expect(await th.text()).toMatchInlineSnapshot(`
268
- "--- from: user (queue-advanced-tester)
269
- Reply with exactly: plugin-timeout-setup
270
- --- from: assistant (TestBot)
271
- *using deterministic-provider/deterministic-v2*
272
- ⬥ ok
273
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
274
- --- from: user (queue-advanced-tester)
275
- PLUGIN_TIMEOUT_SLEEP_MARKER
276
- --- from: assistant (TestBot)
277
- ⬥ starting sleep 100
278
- --- from: user (queue-advanced-tester)
279
- Reply with exactly: plugin-timeout-after
280
- --- from: assistant (TestBot)
281
- ⬥ ok
282
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
283
- `);
284
- expect(afterIndex).toBeGreaterThanOrEqual(0);
285
- const okReplyIndex = messagesWithFooter.findIndex((message, index) => {
286
- if (index <= afterIndex) {
287
- return false;
288
- }
289
- return message.author.id === ctx.discord.botUserId && message.content.includes('ok');
290
- });
291
- expect(okReplyIndex).toBeGreaterThan(afterIndex);
292
- const footerBeforeReply = messagesWithFooter.some((message, index) => {
293
- if (index <= afterIndex || index >= okReplyIndex) {
294
- return false;
295
- }
296
- if (message.author.id !== ctx.discord.botUserId) {
297
- return false;
298
- }
299
- return message.content.startsWith('*') && message.content.includes('⋅');
300
- });
301
- expect(footerBeforeReply).toBe(false);
302
- }, 15_000);
303
- test('tool-call assistant message gets footer when it completes normally', async () => {
304
- // Reproduces the bug: model responds with text + tool call,
305
- // finish="tool-calls", message gets completed timestamp. Then the tool
306
- // result triggers a follow-up text response in a second assistant message.
307
- // The second message gets a footer, but the first (tool-call) message
308
- // should ALSO get a footer since it completed normally.
309
- // This matches the real-world scenario where an agent calls a bash tool
310
- // (e.g. `kimaki send`) and then follows up with a summary text.
248
+ test('tool-call turn emits a footer after the final follow-up', async () => {
311
249
  const existingThreadIds = new Set((await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
312
250
  return thread.id;
313
251
  }));
@@ -337,26 +275,6 @@ e2eTest('queue advanced: footer emission', () => {
337
275
  threadId: thread.id,
338
276
  timeout: 4_000,
339
277
  });
340
- // Poll until both footers have arrived — the first footer (after the
341
- // tool-call step) and the second (after the text follow-up) are emitted
342
- // by sequential handleNaturalAssistantCompletion calls but the second
343
- // may not have hit the Discord thread by the time we first check.
344
- const deadline = Date.now() + 4_000;
345
- let footerCount = 0;
346
- while (Date.now() < deadline) {
347
- const msgs = await th.getMessages();
348
- footerCount = msgs.filter((m) => {
349
- return m.author.id === ctx.discord.botUserId
350
- && m.content.startsWith('*')
351
- && m.content.includes('⋅');
352
- }).length;
353
- if (footerCount >= 2) {
354
- break;
355
- }
356
- await new Promise((resolve) => {
357
- setTimeout(resolve, 100);
358
- });
359
- }
360
278
  expect(await th.text()).toMatchInlineSnapshot(`
361
279
  "--- from: user (queue-advanced-tester)
362
280
  TOOL_CALL_FOOTER_MARKER
@@ -365,131 +283,7 @@ e2eTest('queue advanced: footer emission', () => {
365
283
  ⬥ running tool
366
284
  ┣ bash _echo tool-call-footer-test_
367
285
  ⬥ tool call completed
368
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
286
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
369
287
  `);
370
- // Only ONE footer at the end — the tool-call step's footer is NOT
371
- // emitted mid-turn. The final text follow-up gets the footer.
372
- expect(footerCount).toBe(1);
373
288
  }, 10_000);
374
- test('multi-step tool chain should only have one footer at the end', async () => {
375
- // Model does 3 sequential tool calls (each a separate assistant message
376
- // with finish="tool-calls") then a final text response. Only the final
377
- // text response should get a footer — intermediate tool-call steps
378
- // should NOT get footers since they're mid-turn work.
379
- const existingThreadIds = new Set((await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
380
- return thread.id;
381
- }));
382
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
383
- content: 'MULTI_TOOL_FOOTER_MARKER',
384
- });
385
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
386
- timeout: 6_000,
387
- predicate: (t) => {
388
- return !existingThreadIds.has(t.id);
389
- },
390
- });
391
- const th = ctx.discord.thread(thread.id);
392
- // Wait for the final text response after all 3 tool steps
393
- await waitForBotMessageContaining({
394
- discord: ctx.discord,
395
- threadId: thread.id,
396
- userId: TEST_USER_ID,
397
- text: 'all done, fixed 3 files',
398
- timeout: 6_000,
399
- });
400
- // Wait for the footer after the final response
401
- await waitForFooterMessage({
402
- discord: ctx.discord,
403
- threadId: thread.id,
404
- timeout: 6_000,
405
- });
406
- // Give any spurious extra footers time to arrive
407
- await new Promise((resolve) => {
408
- setTimeout(resolve, 500);
409
- });
410
- const messages = await th.getMessages();
411
- const footerCount = messages.filter((m) => {
412
- return m.author.id === ctx.discord.botUserId
413
- && m.content.startsWith('*')
414
- && m.content.includes('⋅');
415
- }).length;
416
- expect(await th.text()).toMatchInlineSnapshot(`
417
- "--- from: user (queue-advanced-tester)
418
- MULTI_TOOL_FOOTER_MARKER
419
- --- from: assistant (TestBot)
420
- *using deterministic-provider/deterministic-v2*
421
- ⬥ investigating the issue
422
- ┣ bash _echo search-done_
423
- ┣ bash _echo read-done_
424
- ┣ bash _echo fix-done_
425
- ⬥ all done, fixed 3 files
426
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
427
- `);
428
- // Only ONE footer should appear — after the final text response.
429
- // Intermediate tool-call steps should NOT get footers.
430
- expect(footerCount).toBe(1);
431
- }, 10_000);
432
- test('3 sequential tool-call steps produce exactly 1 footer, not 3', async () => {
433
- // This is the most obvious reproduction of the multi-footer bug:
434
- // the model runs 3 sequential tool-call steps (each a SEPARATE
435
- // assistant message with finish="tool-calls"), then a final text.
436
- // With a naive fix that treats tool-calls as natural completions,
437
- // you'd see 4 footers (one per assistant message). Only the final
438
- // text response should produce a footer.
439
- const existingThreadIds = new Set((await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
440
- return thread.id;
441
- }));
442
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
443
- content: 'MULTI_STEP_CHAIN_MARKER',
444
- });
445
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
446
- timeout: 6_000,
447
- predicate: (t) => {
448
- return !existingThreadIds.has(t.id);
449
- },
450
- });
451
- const th = ctx.discord.thread(thread.id);
452
- // Wait for the final text after all 3 sequential tool steps
453
- await waitForBotMessageContaining({
454
- discord: ctx.discord,
455
- threadId: thread.id,
456
- userId: TEST_USER_ID,
457
- text: 'chain complete: all 3 steps done',
458
- timeout: 10_000,
459
- });
460
- // Wait for footer
461
- await waitForFooterMessage({
462
- discord: ctx.discord,
463
- threadId: thread.id,
464
- timeout: 6_000,
465
- });
466
- // Give any spurious extra footers time to arrive
467
- await new Promise((resolve) => {
468
- setTimeout(resolve, 500);
469
- });
470
- const messages = await th.getMessages();
471
- const footerCount = messages.filter((m) => {
472
- return m.author.id === ctx.discord.botUserId
473
- && m.content.startsWith('*')
474
- && m.content.includes('⋅');
475
- }).length;
476
- expect(await th.text()).toMatchInlineSnapshot(`
477
- "--- from: user (queue-advanced-tester)
478
- MULTI_STEP_CHAIN_MARKER
479
- --- from: assistant (TestBot)
480
- *using deterministic-provider/deterministic-v2*
481
- ⬥ chain step 1: reading config
482
- ┣ bash _echo chain-step-1-output_
483
- ⬥ chain step 2: analyzing results
484
- ┣ bash _echo chain-step-2-output_
485
- ⬥ chain step 3: applying fix
486
- ┣ bash _echo chain-step-3-output_
487
- ⬥ chain complete: all 3 steps done
488
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
489
- `);
490
- // The critical assertion: only 1 footer at the very end.
491
- // With the naive "allow tool-calls as natural completion" fix,
492
- // this would be 4 (one per assistant message). We want 1.
493
- expect(footerCount).toBe(1);
494
- }, 15_000);
495
289
  });
@@ -252,7 +252,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
252
252
  --- from: assistant (TestBot)
253
253
  *using deterministic-provider/deterministic-v2*
254
254
  ⬥ ok
255
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
255
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
256
256
  Model set for this session:
257
257
  **Deterministic Provider** / **deterministic-v3**
258
258
  \`deterministic-provider/deterministic-v3\`
@@ -267,7 +267,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
267
267
  Reply with exactly: model-switcher-followup
268
268
  --- from: assistant (TestBot)
269
269
  ⬥ ok
270
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
270
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3* <@200000000000000991>"
271
271
  `);
272
272
  expect(footer).toBeDefined();
273
273
  expect(footer?.content).toContain('deterministic-v3');
@@ -32,7 +32,7 @@ describe('queue advanced: typing around permissions', () => {
32
32
  // every directory, so opt into the --restrict-directories behaviour here.
33
33
  restrictExternalDirectories: true,
34
34
  });
35
- test('permission prompt pauses typing until user click, then typing resumes for long follow-up step', async () => {
35
+ test('permission prompt pauses typing until user click, then typing resumes', async () => {
36
36
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
37
37
  content: 'PERMISSION_TYPING_MARKER',
38
38
  });
@@ -56,7 +56,7 @@ describe('queue advanced: typing around permissions', () => {
56
56
  timeout: 4_000,
57
57
  });
58
58
  th.clearTypingEvents();
59
- await th.waitForTypingEvent({ timeout: 2_000 }).then(() => {
59
+ await th.waitForTypingEvent({ timeout: 700 }).then(() => {
60
60
  throw new Error('Typing should stay paused while permission UI is pending');
61
61
  }, () => {
62
62
  return undefined;
@@ -69,19 +69,19 @@ describe('queue advanced: typing around permissions', () => {
69
69
  interactionId: interaction.id,
70
70
  timeout: 4_000,
71
71
  });
72
- const resumedTyping = await th.waitForTypingEvent({ timeout: 9_000 });
72
+ const resumedTyping = await th.waitForTypingEvent({ timeout: 2_000 });
73
73
  expect(resumedTyping).toBeDefined();
74
74
  await waitForBotMessageContaining({
75
75
  discord: ctx.discord,
76
76
  threadId: thread.id,
77
77
  userId: TEST_USER_ID,
78
78
  text: 'permission-flow-done',
79
- timeout: 12_000,
79
+ timeout: 4_000,
80
80
  });
81
81
  await waitForFooterMessage({
82
82
  discord: ctx.discord,
83
83
  threadId: thread.id,
84
- timeout: 12_000,
84
+ timeout: 4_000,
85
85
  afterMessageIncludes: 'permission-flow-done',
86
86
  afterAuthorId: ctx.discord.botUserId,
87
87
  });
@@ -99,7 +99,7 @@ describe('queue advanced: typing around permissions', () => {
99
99
  ✅ Permission **accepted**
100
100
  [user clicks button]
101
101
  ⬥ permission-flow-done
102
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
102
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
103
103
  `);
104
104
  const timeline = await th.text({
105
105
  showTyping: true,
@@ -112,11 +112,9 @@ describe('queue advanced: typing around permissions', () => {
112
112
  expect(donePosition).toBeGreaterThan(clickPosition);
113
113
  expect(footerPosition).toBeGreaterThan(donePosition);
114
114
  const afterClick = timeline.slice(clickPosition, donePosition);
115
- const afterDone = timeline.slice(donePosition, footerPosition);
116
115
  expect(afterClick).toContain('[bot typing]');
117
- expect(afterDone).toContain('[bot typing]');
118
116
  expect(timeline.slice(footerPosition)).not.toContain('[bot typing]');
119
- }, 20_000);
117
+ }, 12_000);
120
118
  test('manual thread message dismisses pending permission and sends the new prompt', async () => {
121
119
  const initialPrompt = 'PERMISSION_TYPING_MARKER dismiss-flow';
122
120
  const existingThreadIds = new Set((await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {