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
@@ -105,6 +105,32 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
105
105
  },
106
106
  }
107
107
 
108
+ const questionSelectDrainMatcher: DeterministicMatcher = {
109
+ id: 'question-select-drain-first',
110
+ priority: 109,
111
+ when: {
112
+ latestUserTextIncludes: 'QUESTION_SELECT_DRAIN_FIRST_MARKER',
113
+ },
114
+ then: {
115
+ parts: [
116
+ { type: 'stream-start', warnings: [] },
117
+ { type: 'text-start', id: 'question-select-drain-first' },
118
+ {
119
+ type: 'text-delta',
120
+ id: 'question-select-drain-first',
121
+ delta: 'question-drain-first',
122
+ },
123
+ { type: 'text-end', id: 'question-select-drain-first' },
124
+ {
125
+ type: 'finish',
126
+ finishReason: 'stop',
127
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
128
+ },
129
+ ],
130
+ partDelaysMs: [0, 0, 0, 400, 0],
131
+ },
132
+ }
133
+
108
134
  const slowAbortMatcher: DeterministicMatcher = {
109
135
  id: 'slow-abort-marker',
110
136
  priority: 100,
@@ -294,9 +320,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
294
320
  usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
295
321
  },
296
322
  ],
297
- // Keep run busy long enough after permission reply so typing keepalive
298
- // must pulse again. This makes typing resume assertions deterministic.
299
- partDelaysMs: [0, 0, 0, 0, 8_000],
323
+ partDelaysMs: [0, 400, 0, 0, 0],
300
324
  },
301
325
  }
302
326
 
@@ -423,6 +447,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
423
447
  when: {
424
448
  lastMessageRole: 'user',
425
449
  latestUserTextIncludes: 'QUESTION_TEXT_ANSWER_MARKER',
450
+ rawPromptRegex: '^(?!.*question-text-answer-call)',
426
451
  },
427
452
  then: {
428
453
  parts: [
@@ -459,6 +484,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
459
484
  when: {
460
485
  lastMessageRole: 'user',
461
486
  latestUserTextIncludes: 'QUESTION_SELECT_QUEUE_MARKER',
487
+ rawPromptRegex: '^(?!.*question-select-queue-call)',
462
488
  },
463
489
  then: {
464
490
  parts: [
@@ -487,6 +513,50 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
487
513
  },
488
514
  }
489
515
 
516
+ // Text then question, with text-end delayed after the tool call.
517
+ // Reproduces OpenCode emitting question.asked before the text part's time.end.
518
+ const questionAfterTextMatcher: DeterministicMatcher = {
519
+ id: 'question-after-text-marker',
520
+ priority: 108,
521
+ when: {
522
+ lastMessageRole: 'user',
523
+ latestUserTextIncludes: 'QUESTION_AFTER_TEXT_MARKER',
524
+ },
525
+ then: {
526
+ parts: [
527
+ { type: 'stream-start', warnings: [] },
528
+ { type: 'text-start', id: 'plan-before-question' },
529
+ {
530
+ type: 'text-delta',
531
+ id: 'plan-before-question',
532
+ delta: 'PLAN_TEXT_BEFORE_QUESTION',
533
+ },
534
+ {
535
+ type: 'tool-call',
536
+ toolCallId: 'question-after-text-call',
537
+ toolName: 'question',
538
+ input: JSON.stringify({
539
+ questions: [{
540
+ question: 'What next?',
541
+ header: 'Next step',
542
+ options: [
543
+ { label: 'Commit', description: 'Commit these files' },
544
+ { label: 'Stop', description: 'Leave uncommitted' },
545
+ ],
546
+ }],
547
+ }),
548
+ },
549
+ { type: 'text-end', id: 'plan-before-question' },
550
+ {
551
+ type: 'finish',
552
+ finishReason: 'tool-calls',
553
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
554
+ },
555
+ ],
556
+ partDelaysMs: [200, 0, 0, 0, 400, 0],
557
+ },
558
+ }
559
+
490
560
  // Model responds with text + tool call, then after tool result the
491
561
  // follow-up matcher responds with text. This creates two assistant messages:
492
562
  // first with finish="tool-calls" + completed, second with finish="stop".
@@ -806,6 +876,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
806
876
  }
807
877
 
808
878
  return [
879
+ questionSelectDrainMatcher,
809
880
  slowAbortMatcher,
810
881
  slowBusyMatcher,
811
882
  typingRepulseMatcher,
@@ -813,6 +884,7 @@ export function createDeterministicMatchers(): DeterministicMatcher[] {
813
884
  actionButtonClickFollowupMatcher,
814
885
  questionToolMatcher,
815
886
  questionSelectQueueMatcher,
887
+ questionAfterTextMatcher,
816
888
  permissionTypingMatcher,
817
889
  permissionTypingFollowupMatcher,
818
890
  externalDirectoryProbeMatcher,
@@ -857,11 +929,15 @@ export function setupQueueAdvancedSuite({
857
929
  username,
858
930
  restrictExternalDirectories = false,
859
931
  projectPermission,
932
+ extraMatchers = [],
860
933
  }: {
861
934
  channelId: string
862
935
  channelName: string
863
936
  dirName: string
864
937
  username: string
938
+ // Matchers only this suite needs. Keeps narrow markers out of the shared list
939
+ // so they cannot cascade into unrelated e2e files.
940
+ extraMatchers?: DeterministicMatcher[]
865
941
  // Opt into the --restrict-directories behaviour so reads outside the project
866
942
  // still raise an external_directory permission prompt. Off by default, which
867
943
  // matches the shipped default of allowing every directory.
@@ -924,7 +1000,10 @@ export function setupQueueAdvancedSuite({
924
1000
  providerNpm,
925
1001
  model: 'deterministic-v2',
926
1002
  smallModel: 'deterministic-v3',
927
- settings: { strict: false, matchers: createDeterministicMatchers() },
1003
+ settings: {
1004
+ strict: false,
1005
+ matchers: [...createDeterministicMatchers(), ...extraMatchers],
1006
+ },
928
1007
  })
929
1008
  fs.writeFileSync(
930
1009
  path.join(ctx.directories.projectDirectory, 'opencode.json'),
@@ -6,6 +6,7 @@ import {
6
6
  setupQueueAdvancedSuite,
7
7
  TEST_USER_ID,
8
8
  } from './queue-advanced-e2e-setup.js'
9
+ import { store } from './store.js'
9
10
  import {
10
11
  waitForFooterMessage,
11
12
  waitForBotMessageContaining,
@@ -52,7 +53,7 @@ e2eTest('queue advanced: footer emission', () => {
52
53
  --- from: assistant (TestBot)
53
54
  *using deterministic-provider/deterministic-v2*
54
55
  ⬥ ok
55
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
56
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
56
57
  `)
57
58
  const foundFooter = footerMessages.some((m) => {
58
59
  return m.author.id === ctx.discord.botUserId
@@ -64,6 +65,45 @@ e2eTest('queue advanced: footer emission', () => {
64
65
  8_000,
65
66
  )
66
67
 
68
+ test(
69
+ 'skip-footer-mentions omits the thread creator mention',
70
+ async () => {
71
+ store.setState({ footerMentionsEnabled: false })
72
+ try {
73
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
74
+ content: 'Reply with exactly: footer-without-mention',
75
+ })
76
+
77
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
78
+ timeout: 4_000,
79
+ predicate: (t) => {
80
+ return t.name === 'Reply with exactly: footer-without-mention'
81
+ },
82
+ })
83
+
84
+ const th = ctx.discord.thread(thread.id)
85
+ await th.waitForBotReply({ timeout: 4_000 })
86
+ await waitForFooterMessage({
87
+ discord: ctx.discord,
88
+ threadId: thread.id,
89
+ timeout: 4_000,
90
+ })
91
+
92
+ expect(await th.text()).toMatchInlineSnapshot(`
93
+ "--- from: user (queue-advanced-tester)
94
+ Reply with exactly: footer-without-mention
95
+ --- from: assistant (TestBot)
96
+ *using deterministic-provider/deterministic-v2*
97
+ ⬥ ok
98
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
99
+ `)
100
+ } finally {
101
+ store.setState({ footerMentionsEnabled: true })
102
+ }
103
+ },
104
+ 8_000,
105
+ )
106
+
67
107
  test(
68
108
  'footer appears after second message in same session',
69
109
  async () => {
@@ -121,12 +161,12 @@ e2eTest('queue advanced: footer emission', () => {
121
161
  --- from: assistant (TestBot)
122
162
  *using deterministic-provider/deterministic-v2*
123
163
  ⬥ ok
124
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
164
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
125
165
  --- from: user (queue-advanced-tester)
126
166
  Reply with exactly: footer-multi-second
127
167
  --- from: assistant (TestBot)
128
168
  ⬥ ok
129
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
169
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
130
170
  `)
131
171
  if (footerCount >= 2) {
132
172
  expect(footerCount).toBeGreaterThanOrEqual(2)
@@ -235,7 +275,7 @@ e2eTest('queue advanced: footer emission', () => {
235
275
  --- from: assistant (TestBot)
236
276
  *using deterministic-provider/deterministic-v2*
237
277
  ⬥ ok
238
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
278
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
239
279
  --- from: user (queue-advanced-tester)
240
280
  PLUGIN_TIMEOUT_SLEEP_MARKER
241
281
  --- from: assistant (TestBot)
@@ -244,7 +284,7 @@ e2eTest('queue advanced: footer emission', () => {
244
284
  Reply with exactly: interrupt-footer-followup
245
285
  --- from: assistant (TestBot)
246
286
  ⬥ ok
247
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
287
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
248
288
  `)
249
289
  expect(followupUserIdx).toBeGreaterThanOrEqual(0)
250
290
  expect(okReplyIdx).toBeGreaterThan(followupUserIdx)
@@ -263,120 +303,8 @@ e2eTest('queue advanced: footer emission', () => {
263
303
  )
264
304
 
265
305
  test(
266
- 'plugin timeout interrupt aborts slow sleep and avoids intermediate footer',
267
- async () => {
268
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
269
- content: 'Reply with exactly: plugin-timeout-setup',
270
- })
271
-
272
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
273
- timeout: 4_000,
274
- predicate: (t) => {
275
- return t.name === 'Reply with exactly: plugin-timeout-setup'
276
- },
277
- })
278
-
279
- const th = ctx.discord.thread(thread.id)
280
- await th.waitForBotReply({ timeout: 4_000 })
281
- await waitForBotMessageContaining({
282
- discord: ctx.discord,
283
- threadId: thread.id,
284
- userId: TEST_USER_ID,
285
- text: '*project',
286
- timeout: 4_000,
287
- })
288
-
289
- await th.user(TEST_USER_ID).sendMessage({
290
- content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
291
- })
292
-
293
- await waitForBotMessageContaining({
294
- discord: ctx.discord,
295
- threadId: thread.id,
296
- userId: TEST_USER_ID,
297
- text: 'starting sleep 100',
298
- afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
299
- timeout: 4_000,
300
- })
301
-
302
- await th.user(TEST_USER_ID).sendMessage({
303
- content: 'Reply with exactly: plugin-timeout-after',
304
- })
305
-
306
- const messages = await waitForBotMessageContaining({
307
- discord: ctx.discord,
308
- threadId: thread.id,
309
- userId: TEST_USER_ID,
310
- text: 'ok',
311
- afterUserMessageIncludes: 'plugin-timeout-after',
312
- timeout: 12_000,
313
- })
314
-
315
- const messagesWithFooter = await waitForFooterMessage({
316
- discord: ctx.discord,
317
- threadId: thread.id,
318
- timeout: 12_000,
319
- afterMessageIncludes: 'plugin-timeout-after',
320
- afterAuthorId: TEST_USER_ID,
321
- })
322
-
323
- const afterIndex = messagesWithFooter.findIndex((message) => {
324
- return (
325
- message.author.id === TEST_USER_ID
326
- && message.content.includes('plugin-timeout-after')
327
- )
328
- })
329
- expect(await th.text()).toMatchInlineSnapshot(`
330
- "--- from: user (queue-advanced-tester)
331
- Reply with exactly: plugin-timeout-setup
332
- --- from: assistant (TestBot)
333
- *using deterministic-provider/deterministic-v2*
334
- ⬥ ok
335
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
336
- --- from: user (queue-advanced-tester)
337
- PLUGIN_TIMEOUT_SLEEP_MARKER
338
- --- from: assistant (TestBot)
339
- ⬥ starting sleep 100
340
- --- from: user (queue-advanced-tester)
341
- Reply with exactly: plugin-timeout-after
342
- --- from: assistant (TestBot)
343
- ⬥ ok
344
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
345
- `)
346
- expect(afterIndex).toBeGreaterThanOrEqual(0)
347
-
348
- const okReplyIndex = messagesWithFooter.findIndex((message, index) => {
349
- if (index <= afterIndex) {
350
- return false
351
- }
352
- return message.author.id === ctx.discord.botUserId && message.content.includes('ok')
353
- })
354
- expect(okReplyIndex).toBeGreaterThan(afterIndex)
355
-
356
- const footerBeforeReply = messagesWithFooter.some((message, index) => {
357
- if (index <= afterIndex || index >= okReplyIndex) {
358
- return false
359
- }
360
- if (message.author.id !== ctx.discord.botUserId) {
361
- return false
362
- }
363
- return message.content.startsWith('*') && message.content.includes('⋅')
364
- })
365
- expect(footerBeforeReply).toBe(false)
366
- },
367
- 15_000,
368
- )
369
-
370
- test(
371
- 'tool-call assistant message gets footer when it completes normally',
306
+ 'tool-call turn emits a footer after the final follow-up',
372
307
  async () => {
373
- // Reproduces the bug: model responds with text + tool call,
374
- // finish="tool-calls", message gets completed timestamp. Then the tool
375
- // result triggers a follow-up text response in a second assistant message.
376
- // The second message gets a footer, but the first (tool-call) message
377
- // should ALSO get a footer since it completed normally.
378
- // This matches the real-world scenario where an agent calls a bash tool
379
- // (e.g. `kimaki send`) and then follows up with a summary text.
380
308
  const existingThreadIds = new Set(
381
309
  (await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
382
310
  return thread.id
@@ -413,27 +341,6 @@ e2eTest('queue advanced: footer emission', () => {
413
341
  timeout: 4_000,
414
342
  })
415
343
 
416
- // Poll until both footers have arrived — the first footer (after the
417
- // tool-call step) and the second (after the text follow-up) are emitted
418
- // by sequential handleNaturalAssistantCompletion calls but the second
419
- // may not have hit the Discord thread by the time we first check.
420
- const deadline = Date.now() + 4_000
421
- let footerCount = 0
422
- while (Date.now() < deadline) {
423
- const msgs = await th.getMessages()
424
- footerCount = msgs.filter((m) => {
425
- return m.author.id === ctx.discord.botUserId
426
- && m.content.startsWith('*')
427
- && m.content.includes('⋅')
428
- }).length
429
- if (footerCount >= 2) {
430
- break
431
- }
432
- await new Promise((resolve) => {
433
- setTimeout(resolve, 100)
434
- })
435
- }
436
-
437
344
  expect(await th.text()).toMatchInlineSnapshot(`
438
345
  "--- from: user (queue-advanced-tester)
439
346
  TOOL_CALL_FOOTER_MARKER
@@ -442,164 +349,10 @@ e2eTest('queue advanced: footer emission', () => {
442
349
  ⬥ running tool
443
350
  ┣ bash _echo tool-call-footer-test_
444
351
  ⬥ tool call completed
445
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
352
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
446
353
  `)
447
-
448
- // Only ONE footer at the end — the tool-call step's footer is NOT
449
- // emitted mid-turn. The final text follow-up gets the footer.
450
- expect(footerCount).toBe(1)
451
354
  },
452
355
  10_000,
453
356
  )
454
357
 
455
- test(
456
- 'multi-step tool chain should only have one footer at the end',
457
- async () => {
458
- // Model does 3 sequential tool calls (each a separate assistant message
459
- // with finish="tool-calls") then a final text response. Only the final
460
- // text response should get a footer — intermediate tool-call steps
461
- // should NOT get footers since they're mid-turn work.
462
- const existingThreadIds = new Set(
463
- (await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
464
- return thread.id
465
- }),
466
- )
467
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
468
- content: 'MULTI_TOOL_FOOTER_MARKER',
469
- })
470
-
471
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
472
- timeout: 6_000,
473
- predicate: (t) => {
474
- return !existingThreadIds.has(t.id)
475
- },
476
- })
477
-
478
- const th = ctx.discord.thread(thread.id)
479
-
480
- // Wait for the final text response after all 3 tool steps
481
- await waitForBotMessageContaining({
482
- discord: ctx.discord,
483
- threadId: thread.id,
484
- userId: TEST_USER_ID,
485
- text: 'all done, fixed 3 files',
486
- timeout: 6_000,
487
- })
488
-
489
- // Wait for the footer after the final response
490
- await waitForFooterMessage({
491
- discord: ctx.discord,
492
- threadId: thread.id,
493
- timeout: 6_000,
494
- })
495
-
496
- // Give any spurious extra footers time to arrive
497
- await new Promise((resolve) => {
498
- setTimeout(resolve, 500)
499
- })
500
-
501
- const messages = await th.getMessages()
502
- const footerCount = messages.filter((m) => {
503
- return m.author.id === ctx.discord.botUserId
504
- && m.content.startsWith('*')
505
- && m.content.includes('⋅')
506
- }).length
507
-
508
- expect(await th.text()).toMatchInlineSnapshot(`
509
- "--- from: user (queue-advanced-tester)
510
- MULTI_TOOL_FOOTER_MARKER
511
- --- from: assistant (TestBot)
512
- *using deterministic-provider/deterministic-v2*
513
- ⬥ investigating the issue
514
- ┣ bash _echo search-done_
515
- ┣ bash _echo read-done_
516
- ┣ bash _echo fix-done_
517
- ⬥ all done, fixed 3 files
518
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
519
- `)
520
-
521
- // Only ONE footer should appear — after the final text response.
522
- // Intermediate tool-call steps should NOT get footers.
523
- expect(footerCount).toBe(1)
524
- },
525
- 10_000,
526
- )
527
-
528
- test(
529
- '3 sequential tool-call steps produce exactly 1 footer, not 3',
530
- async () => {
531
- // This is the most obvious reproduction of the multi-footer bug:
532
- // the model runs 3 sequential tool-call steps (each a SEPARATE
533
- // assistant message with finish="tool-calls"), then a final text.
534
- // With a naive fix that treats tool-calls as natural completions,
535
- // you'd see 4 footers (one per assistant message). Only the final
536
- // text response should produce a footer.
537
- const existingThreadIds = new Set(
538
- (await ctx.discord.channel(TEXT_CHANNEL_ID).getThreads()).map((thread) => {
539
- return thread.id
540
- }),
541
- )
542
- await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
543
- content: 'MULTI_STEP_CHAIN_MARKER',
544
- })
545
-
546
- const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
547
- timeout: 6_000,
548
- predicate: (t) => {
549
- return !existingThreadIds.has(t.id)
550
- },
551
- })
552
-
553
- const th = ctx.discord.thread(thread.id)
554
-
555
- // Wait for the final text after all 3 sequential tool steps
556
- await waitForBotMessageContaining({
557
- discord: ctx.discord,
558
- threadId: thread.id,
559
- userId: TEST_USER_ID,
560
- text: 'chain complete: all 3 steps done',
561
- timeout: 10_000,
562
- })
563
-
564
- // Wait for footer
565
- await waitForFooterMessage({
566
- discord: ctx.discord,
567
- threadId: thread.id,
568
- timeout: 6_000,
569
- })
570
-
571
- // Give any spurious extra footers time to arrive
572
- await new Promise((resolve) => {
573
- setTimeout(resolve, 500)
574
- })
575
-
576
- const messages = await th.getMessages()
577
- const footerCount = messages.filter((m) => {
578
- return m.author.id === ctx.discord.botUserId
579
- && m.content.startsWith('*')
580
- && m.content.includes('⋅')
581
- }).length
582
-
583
- expect(await th.text()).toMatchInlineSnapshot(`
584
- "--- from: user (queue-advanced-tester)
585
- MULTI_STEP_CHAIN_MARKER
586
- --- from: assistant (TestBot)
587
- *using deterministic-provider/deterministic-v2*
588
- ⬥ chain step 1: reading config
589
- ┣ bash _echo chain-step-1-output_
590
- ⬥ chain step 2: analyzing results
591
- ┣ bash _echo chain-step-2-output_
592
- ⬥ chain step 3: applying fix
593
- ┣ bash _echo chain-step-3-output_
594
- ⬥ chain complete: all 3 steps done
595
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
596
- `)
597
-
598
- // The critical assertion: only 1 footer at the very end.
599
- // With the naive "allow tool-calls as natural completion" fix,
600
- // this would be 4 (one per assistant message). We want 1.
601
- expect(footerCount).toBe(1)
602
- },
603
- 15_000,
604
- )
605
358
  })
@@ -330,7 +330,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
330
330
  --- from: assistant (TestBot)
331
331
  *using deterministic-provider/deterministic-v2*
332
332
  ⬥ ok
333
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
333
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
334
334
  Model set for this session:
335
335
  **Deterministic Provider** / **deterministic-v3**
336
336
  \`deterministic-provider/deterministic-v3\`
@@ -345,7 +345,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
345
345
  Reply with exactly: model-switcher-followup
346
346
  --- from: assistant (TestBot)
347
347
  ⬥ ok
348
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
348
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3* <@200000000000000991>"
349
349
  `)
350
350
 
351
351
  expect(footer).toBeDefined()
@@ -51,7 +51,7 @@ describe('queue advanced: typing around permissions', () => {
51
51
  })
52
52
 
53
53
  test(
54
- 'permission prompt pauses typing until user click, then typing resumes for long follow-up step',
54
+ 'permission prompt pauses typing until user click, then typing resumes',
55
55
  async () => {
56
56
  await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
57
57
  content: 'PERMISSION_TYPING_MARKER',
@@ -83,7 +83,7 @@ describe('queue advanced: typing around permissions', () => {
83
83
 
84
84
  th.clearTypingEvents()
85
85
 
86
- await th.waitForTypingEvent({ timeout: 2_000 }).then(
86
+ await th.waitForTypingEvent({ timeout: 700 }).then(
87
87
  () => {
88
88
  throw new Error('Typing should stay paused while permission UI is pending')
89
89
  },
@@ -102,7 +102,7 @@ describe('queue advanced: typing around permissions', () => {
102
102
  timeout: 4_000,
103
103
  })
104
104
 
105
- const resumedTyping = await th.waitForTypingEvent({ timeout: 9_000 })
105
+ const resumedTyping = await th.waitForTypingEvent({ timeout: 2_000 })
106
106
  expect(resumedTyping).toBeDefined()
107
107
 
108
108
  await waitForBotMessageContaining({
@@ -110,13 +110,13 @@ describe('queue advanced: typing around permissions', () => {
110
110
  threadId: thread.id,
111
111
  userId: TEST_USER_ID,
112
112
  text: 'permission-flow-done',
113
- timeout: 12_000,
113
+ timeout: 4_000,
114
114
  })
115
115
 
116
116
  await waitForFooterMessage({
117
117
  discord: ctx.discord,
118
118
  threadId: thread.id,
119
- timeout: 12_000,
119
+ timeout: 4_000,
120
120
  afterMessageIncludes: 'permission-flow-done',
121
121
  afterAuthorId: ctx.discord.botUserId,
122
122
  })
@@ -135,7 +135,7 @@ describe('queue advanced: typing around permissions', () => {
135
135
  ✅ Permission **accepted**
136
136
  [user clicks button]
137
137
  ⬥ permission-flow-done
138
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
138
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
139
139
  `)
140
140
 
141
141
  const timeline = await th.text({
@@ -150,12 +150,10 @@ describe('queue advanced: typing around permissions', () => {
150
150
  expect(footerPosition).toBeGreaterThan(donePosition)
151
151
 
152
152
  const afterClick = timeline.slice(clickPosition, donePosition)
153
- const afterDone = timeline.slice(donePosition, footerPosition)
154
153
  expect(afterClick).toContain('[bot typing]')
155
- expect(afterDone).toContain('[bot typing]')
156
154
  expect(timeline.slice(footerPosition)).not.toContain('[bot typing]')
157
155
  },
158
- 20_000,
156
+ 12_000,
159
157
  )
160
158
 
161
159
  test(