kimaki 0.4.76 → 0.4.78

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 (162) hide show
  1. package/dist/adapter-rest-boundary.test.js +34 -0
  2. package/dist/agent-model.e2e.test.js +2 -20
  3. package/dist/cli.js +50 -13
  4. package/dist/commands/channel-ref.js +16 -0
  5. package/dist/commands/diff.js +20 -85
  6. package/dist/commands/merge-worktree.js +5 -17
  7. package/dist/commands/new-worktree.js +5 -9
  8. package/dist/commands/permissions.js +77 -11
  9. package/dist/commands/resume.js +5 -9
  10. package/dist/commands/screenshare.js +295 -0
  11. package/dist/commands/session.js +6 -17
  12. package/dist/critique-utils.js +95 -0
  13. package/dist/diff-patch-plugin.js +314 -0
  14. package/dist/discord-bot.js +19 -14
  15. package/dist/discord-js-import-boundary.test.js +62 -0
  16. package/dist/discord-utils.js +44 -0
  17. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  18. package/dist/gateway-proxy.e2e.test.js +2 -5
  19. package/dist/generated/cloudflare/browser.js +17 -0
  20. package/dist/generated/cloudflare/client.js +34 -0
  21. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  22. package/dist/generated/cloudflare/enums.js +48 -0
  23. package/dist/generated/cloudflare/internal/class.js +47 -0
  24. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  25. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  26. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  27. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  28. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  31. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  32. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  33. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  34. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  35. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  36. package/dist/generated/cloudflare/models/global_models.js +1 -0
  37. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  38. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  39. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  40. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  41. package/dist/generated/cloudflare/models/session_events.js +1 -0
  42. package/dist/generated/cloudflare/models/session_models.js +1 -0
  43. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  44. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  45. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  46. package/dist/generated/cloudflare/models.js +1 -0
  47. package/dist/generated/node/browser.js +17 -0
  48. package/dist/generated/node/client.js +37 -0
  49. package/dist/generated/node/commonInputTypes.js +10 -0
  50. package/dist/generated/node/enums.js +48 -0
  51. package/dist/generated/node/internal/class.js +49 -0
  52. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  53. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  54. package/dist/generated/node/models/bot_api_keys.js +1 -0
  55. package/dist/generated/node/models/bot_tokens.js +1 -0
  56. package/dist/generated/node/models/channel_agents.js +1 -0
  57. package/dist/generated/node/models/channel_directories.js +1 -0
  58. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  59. package/dist/generated/node/models/channel_models.js +1 -0
  60. package/dist/generated/node/models/channel_verbosity.js +1 -0
  61. package/dist/generated/node/models/channel_worktrees.js +1 -0
  62. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  63. package/dist/generated/node/models/global_models.js +1 -0
  64. package/dist/generated/node/models/ipc_requests.js +1 -0
  65. package/dist/generated/node/models/part_messages.js +1 -0
  66. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  67. package/dist/generated/node/models/session_agents.js +1 -0
  68. package/dist/generated/node/models/session_events.js +1 -0
  69. package/dist/generated/node/models/session_models.js +1 -0
  70. package/dist/generated/node/models/session_start_sources.js +1 -0
  71. package/dist/generated/node/models/thread_sessions.js +1 -0
  72. package/dist/generated/node/models/thread_worktrees.js +1 -0
  73. package/dist/generated/node/models.js +1 -0
  74. package/dist/interaction-handler.js +10 -0
  75. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  76. package/dist/message-flags-boundary.test.js +54 -0
  77. package/dist/message-formatting.js +3 -62
  78. package/dist/onboarding-tutorial-plugin.js +1 -1
  79. package/dist/opencode-command.js +129 -0
  80. package/dist/opencode-command.test.js +48 -0
  81. package/dist/opencode-interrupt-plugin.js +19 -1
  82. package/dist/opencode-interrupt-plugin.test.js +0 -5
  83. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  84. package/dist/opencode-plugin.js +4 -4
  85. package/dist/opencode.js +150 -27
  86. package/dist/patch-text-parser.js +97 -0
  87. package/dist/platform/components-v2.js +20 -0
  88. package/dist/platform/discord-adapter.js +1440 -0
  89. package/dist/platform/discord-routes.js +31 -0
  90. package/dist/platform/message-flags.js +8 -0
  91. package/dist/platform/platform-value.js +41 -0
  92. package/dist/platform/slack-adapter.js +872 -0
  93. package/dist/platform/slack-markdown.js +169 -0
  94. package/dist/platform/types.js +4 -0
  95. package/dist/queue-advanced-e2e-setup.js +265 -0
  96. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  97. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  98. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  99. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  100. package/dist/session-handler/event-stream-state.js +5 -0
  101. package/dist/session-handler/event-stream-state.test.js +6 -2
  102. package/dist/session-handler/thread-session-runtime.js +32 -2
  103. package/dist/system-message.js +26 -23
  104. package/dist/test-utils.js +16 -0
  105. package/dist/thread-message-queue.e2e.test.js +2 -20
  106. package/dist/utils.js +3 -1
  107. package/dist/voice-message.e2e.test.js +2 -20
  108. package/dist/voice.js +122 -9
  109. package/dist/voice.test.js +17 -2
  110. package/dist/websockify.js +69 -0
  111. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  112. package/package.json +4 -2
  113. package/skills/critique/SKILL.md +17 -0
  114. package/skills/egaki/SKILL.md +35 -0
  115. package/skills/event-sourcing-state/SKILL.md +252 -0
  116. package/skills/goke/SKILL.md +1 -0
  117. package/skills/npm-package/SKILL.md +21 -2
  118. package/skills/playwriter/SKILL.md +1 -1
  119. package/skills/x-articles/SKILL.md +554 -0
  120. package/src/agent-model.e2e.test.ts +4 -19
  121. package/src/cli.ts +60 -13
  122. package/src/commands/diff.ts +25 -99
  123. package/src/commands/merge-worktree.ts +5 -21
  124. package/src/commands/new-worktree.ts +5 -11
  125. package/src/commands/permissions.ts +100 -15
  126. package/src/commands/resume.ts +5 -12
  127. package/src/commands/screenshare.ts +354 -0
  128. package/src/commands/session.ts +6 -23
  129. package/src/critique-utils.ts +139 -0
  130. package/src/discord-bot.ts +20 -15
  131. package/src/discord-utils.ts +53 -0
  132. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  133. package/src/gateway-proxy.e2e.test.ts +2 -5
  134. package/src/interaction-handler.ts +15 -0
  135. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  136. package/src/message-formatting.ts +3 -68
  137. package/src/onboarding-tutorial-plugin.ts +1 -1
  138. package/src/opencode-command.test.ts +70 -0
  139. package/src/opencode-command.ts +188 -0
  140. package/src/opencode-interrupt-plugin.test.ts +0 -5
  141. package/src/opencode-interrupt-plugin.ts +34 -1
  142. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  143. package/src/opencode-plugin.ts +5 -4
  144. package/src/opencode.ts +199 -32
  145. package/src/patch-text-parser.ts +107 -0
  146. package/src/queue-advanced-e2e-setup.ts +273 -0
  147. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  148. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  149. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  150. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  151. package/src/session-handler/event-stream-state.test.ts +6 -2
  152. package/src/session-handler/event-stream-state.ts +5 -0
  153. package/src/session-handler/thread-session-runtime.ts +45 -2
  154. package/src/system-message.ts +26 -23
  155. package/src/test-utils.ts +17 -0
  156. package/src/thread-message-queue.e2e.test.ts +2 -20
  157. package/src/utils.ts +3 -1
  158. package/src/voice-message.e2e.test.ts +3 -20
  159. package/src/voice.test.ts +26 -2
  160. package/src/voice.ts +147 -9
  161. package/src/websockify.ts +101 -0
  162. package/src/worktree-lifecycle.e2e.test.ts +391 -0
@@ -0,0 +1,383 @@
1
+ // E2e test for /model switch behavior through interrupt recovery.
2
+ // Reproduces fallback where interrupt plugin resume can run without model,
3
+ // causing default opencode.json model to be used after switching session model.
4
+
5
+ import { describe, test, expect } from 'vitest'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import type { DigitalDiscord } from 'discord-digital-twin/src'
9
+ import {
10
+ setupQueueAdvancedSuite,
11
+ TEST_USER_ID,
12
+ } from './queue-advanced-e2e-setup.js'
13
+ import {
14
+ waitForBotMessageContaining,
15
+ waitForBotReplyAfterUserMessage,
16
+ waitForFooterMessage,
17
+ waitForMessageById,
18
+ } from './test-utils.js'
19
+ import { getThreadState } from './session-handler/thread-runtime-state.js'
20
+ import { getSessionModel } from './database.js'
21
+ import { initializeOpencodeForDirectory } from './opencode.js'
22
+
23
+ const TEXT_CHANNEL_ID = '200000000000001007'
24
+
25
+ function getCustomIdFromInteractionData({
26
+ serializedComponents,
27
+ prefix,
28
+ }: {
29
+ serializedComponents: string
30
+ prefix: string
31
+ }): string {
32
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
33
+ const customIdRegex = new RegExp(`\"custom_id\"\\s*:\\s*\"(${escapedPrefix}[^\"]+)\"`)
34
+ const match = serializedComponents.match(customIdRegex)
35
+ if (!match?.[1]) {
36
+ throw new Error(
37
+ `Could not find custom_id with prefix ${prefix} in components: ${serializedComponents}`,
38
+ )
39
+ }
40
+ return match[1]
41
+ }
42
+
43
+ async function waitForMessageComponentsWithCustomId({
44
+ discord,
45
+ threadId,
46
+ messageId,
47
+ customIdPrefix,
48
+ timeoutMs,
49
+ }: {
50
+ discord: DigitalDiscord
51
+ threadId: string
52
+ messageId: string
53
+ customIdPrefix: string
54
+ timeoutMs: number
55
+ }): Promise<string> {
56
+ const start = Date.now()
57
+ while (Date.now() - start < timeoutMs) {
58
+ const message = await waitForMessageById({
59
+ discord,
60
+ threadId,
61
+ messageId,
62
+ timeout: 1_000,
63
+ })
64
+ const serializedComponents = JSON.stringify(message.components)
65
+ if (serializedComponents.includes(customIdPrefix)) {
66
+ return serializedComponents
67
+ }
68
+ await new Promise<void>((resolve) => {
69
+ setTimeout(resolve, 50)
70
+ })
71
+ }
72
+ throw new Error(
73
+ `Timed out waiting for custom_id prefix ${customIdPrefix} in message ${messageId}`,
74
+ )
75
+ }
76
+
77
+ async function waitForInteractionMessage({
78
+ getInteraction,
79
+ interactionId,
80
+ timeoutMs,
81
+ }: {
82
+ getInteraction: (interactionId: string) => Promise<{
83
+ messageId: string | null
84
+ data: string | null
85
+ } | null>
86
+ interactionId: string
87
+ timeoutMs: number
88
+ }): Promise<{ messageId: string; data: string }> {
89
+ const start = Date.now()
90
+ while (Date.now() - start < timeoutMs) {
91
+ const response = await getInteraction(interactionId)
92
+ if (response?.messageId) {
93
+ return {
94
+ messageId: response.messageId,
95
+ data: response.data || '',
96
+ }
97
+ }
98
+ await new Promise<void>((resolve) => {
99
+ setTimeout(resolve, 50)
100
+ })
101
+ }
102
+ throw new Error(`Timed out waiting for interaction message ${interactionId}`)
103
+ }
104
+
105
+ describe('queue advanced: /model with interrupt recovery', () => {
106
+ const ctx = setupQueueAdvancedSuite({
107
+ channelId: TEXT_CHANNEL_ID,
108
+ channelName: 'qa-model-switch-e2e',
109
+ dirName: 'qa-model-switch-e2e',
110
+ username: 'queue-model-switch-tester',
111
+ })
112
+
113
+ test(
114
+ 'session model selected in /model survives interrupt-plugin resume path',
115
+ async () => {
116
+ const buildAgentDir = path.join(
117
+ ctx.directories.projectDirectory,
118
+ '.opencode',
119
+ 'agent',
120
+ )
121
+ fs.mkdirSync(buildAgentDir, { recursive: true })
122
+ fs.writeFileSync(
123
+ path.join(buildAgentDir, 'build.md'),
124
+ [
125
+ '---',
126
+ 'name: build',
127
+ 'description: Default build agent for deterministic model tests',
128
+ 'model: deterministic-provider/deterministic-v2',
129
+ '---',
130
+ '',
131
+ 'You are the default build agent.',
132
+ '',
133
+ ].join('\n'),
134
+ )
135
+
136
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
137
+ content: 'Reply with exactly: model-switcher-setup',
138
+ })
139
+
140
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
141
+ timeout: 4_000,
142
+ predicate: (t) => {
143
+ return t.name === 'Reply with exactly: model-switcher-setup'
144
+ },
145
+ })
146
+ const th = ctx.discord.thread(thread.id)
147
+
148
+ await th.waitForBotReply({ timeout: 4_000 })
149
+ await waitForFooterMessage({
150
+ discord: ctx.discord,
151
+ threadId: thread.id,
152
+ timeout: 4_000,
153
+ })
154
+
155
+ const modelCommand = await th.user(TEST_USER_ID).runSlashCommand({
156
+ name: 'model',
157
+ })
158
+ await th.waitForInteractionAck({
159
+ interactionId: modelCommand.id,
160
+ timeout: 4_000,
161
+ })
162
+
163
+ const providerStep = await waitForInteractionMessage({
164
+ getInteraction: (interactionId) => {
165
+ return th.getInteractionResponse(interactionId)
166
+ },
167
+ interactionId: modelCommand.id,
168
+ timeoutMs: 4_000,
169
+ })
170
+ const providerCustomId = getCustomIdFromInteractionData({
171
+ serializedComponents: await waitForMessageComponentsWithCustomId({
172
+ discord: ctx.discord,
173
+ threadId: thread.id,
174
+ messageId: providerStep.messageId,
175
+ customIdPrefix: 'model_provider:',
176
+ timeoutMs: 4_000,
177
+ }),
178
+ prefix: 'model_provider:',
179
+ })
180
+
181
+ const providerSelect = await th.user(TEST_USER_ID).selectMenu({
182
+ messageId: providerStep.messageId,
183
+ customId: providerCustomId,
184
+ values: ['deterministic-provider'],
185
+ })
186
+ await th.waitForInteractionAck({
187
+ interactionId: providerSelect.id,
188
+ timeout: 4_000,
189
+ })
190
+
191
+ const modelStep = await waitForInteractionMessage({
192
+ getInteraction: (interactionId) => {
193
+ return th.getInteractionResponse(interactionId)
194
+ },
195
+ interactionId: providerSelect.id,
196
+ timeoutMs: 4_000,
197
+ })
198
+ const modelCustomId = getCustomIdFromInteractionData({
199
+ serializedComponents: await waitForMessageComponentsWithCustomId({
200
+ discord: ctx.discord,
201
+ threadId: thread.id,
202
+ messageId: modelStep.messageId,
203
+ customIdPrefix: 'model_select:',
204
+ timeoutMs: 4_000,
205
+ }),
206
+ prefix: 'model_select:',
207
+ })
208
+
209
+ const modelSelect = await th.user(TEST_USER_ID).selectMenu({
210
+ messageId: modelStep.messageId,
211
+ customId: modelCustomId,
212
+ values: ['deterministic-v3'],
213
+ })
214
+ await th.waitForInteractionAck({
215
+ interactionId: modelSelect.id,
216
+ timeout: 4_000,
217
+ })
218
+
219
+ const maybeVariantOrScopeStep = await waitForInteractionMessage({
220
+ getInteraction: (interactionId) => {
221
+ return th.getInteractionResponse(interactionId)
222
+ },
223
+ interactionId: modelSelect.id,
224
+ timeoutMs: 4_000,
225
+ })
226
+
227
+ const maybeVariantOrScopeMessage = await waitForMessageById({
228
+ discord: ctx.discord,
229
+ threadId: thread.id,
230
+ messageId: maybeVariantOrScopeStep.messageId,
231
+ timeout: 4_000,
232
+ })
233
+ const maybeVariantOrScopeComponents = JSON.stringify(
234
+ maybeVariantOrScopeMessage.components,
235
+ )
236
+
237
+ const scopeStep = maybeVariantOrScopeComponents.includes('model_variant:')
238
+ ? await (async () => {
239
+ const variantCustomId = getCustomIdFromInteractionData({
240
+ serializedComponents: maybeVariantOrScopeComponents,
241
+ prefix: 'model_variant:',
242
+ })
243
+ const variantSelect = await th.user(TEST_USER_ID).selectMenu({
244
+ messageId: maybeVariantOrScopeStep.messageId,
245
+ customId: variantCustomId,
246
+ values: ['__none__'],
247
+ })
248
+ await th.waitForInteractionAck({
249
+ interactionId: variantSelect.id,
250
+ timeout: 4_000,
251
+ })
252
+ return waitForInteractionMessage({
253
+ getInteraction: (interactionId) => {
254
+ return th.getInteractionResponse(interactionId)
255
+ },
256
+ interactionId: variantSelect.id,
257
+ timeoutMs: 4_000,
258
+ })
259
+ })()
260
+ : maybeVariantOrScopeStep
261
+
262
+ const scopeCustomId = getCustomIdFromInteractionData({
263
+ serializedComponents: await waitForMessageComponentsWithCustomId({
264
+ discord: ctx.discord,
265
+ threadId: thread.id,
266
+ messageId: scopeStep.messageId,
267
+ customIdPrefix: 'model_scope:',
268
+ timeoutMs: 4_000,
269
+ }),
270
+ prefix: 'model_scope:',
271
+ })
272
+
273
+ const scopeSelect = await th.user(TEST_USER_ID).selectMenu({
274
+ messageId: scopeStep.messageId,
275
+ customId: scopeCustomId,
276
+ values: ['session'],
277
+ })
278
+ await th.waitForInteractionAck({
279
+ interactionId: scopeSelect.id,
280
+ timeout: 4_000,
281
+ })
282
+
283
+ const sessionId = getThreadState(thread.id)?.sessionId
284
+ expect(sessionId).toBeDefined()
285
+ if (!sessionId) {
286
+ throw new Error('Expected session id to be present after /model selection')
287
+ }
288
+ const sessionModel = await getSessionModel(sessionId)
289
+ expect(sessionModel?.modelId).toBe('deterministic-provider/deterministic-v3')
290
+
291
+ await th.user(TEST_USER_ID).sendMessage({
292
+ content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
293
+ })
294
+ await waitForBotMessageContaining({
295
+ discord: ctx.discord,
296
+ threadId: thread.id,
297
+ userId: TEST_USER_ID,
298
+ text: 'starting sleep',
299
+ afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
300
+ timeout: 4_000,
301
+ })
302
+
303
+ await th.user(TEST_USER_ID).sendMessage({
304
+ content: 'Reply with exactly: model-switcher-followup',
305
+ })
306
+
307
+ await waitForBotReplyAfterUserMessage({
308
+ discord: ctx.discord,
309
+ threadId: thread.id,
310
+ userId: TEST_USER_ID,
311
+ userMessageIncludes: 'model-switcher-followup',
312
+ timeout: 8_000,
313
+ })
314
+ const finalMessages = await waitForFooterMessage({
315
+ discord: ctx.discord,
316
+ threadId: thread.id,
317
+ timeout: 8_000,
318
+ afterMessageIncludes: 'model-switcher-followup',
319
+ afterAuthorId: TEST_USER_ID,
320
+ })
321
+
322
+ const footer = [...finalMessages].reverse().find((message) => {
323
+ return message.author.id === ctx.discord.botUserId
324
+ && message.content.startsWith('*')
325
+ && message.content.includes('⋅')
326
+ })
327
+ expect(await th.text()).toMatchInlineSnapshot(`
328
+ "--- from: user (queue-model-switch-tester)
329
+ Reply with exactly: model-switcher-setup
330
+ --- from: assistant (TestBot)
331
+ ⬥ ok
332
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
333
+ Model set for this session:
334
+ **Deterministic Provider** / **deterministic-v3**
335
+ \`deterministic-provider/deterministic-v3\`
336
+ _Restarting current request with new model..._
337
+ _Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_
338
+ --- from: user (queue-model-switch-tester)
339
+ PLUGIN_TIMEOUT_SLEEP_MARKER
340
+ --- from: assistant (TestBot)
341
+ ⬥ ok
342
+ ⬥ starting sleep 100
343
+ --- from: user (queue-model-switch-tester)
344
+ Reply with exactly: model-switcher-followup
345
+ --- from: assistant (TestBot)
346
+ ⬥ ok
347
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
348
+ `)
349
+
350
+ expect(footer).toBeDefined()
351
+ expect(footer?.content).toContain('deterministic-v3')
352
+
353
+ const getClient = await initializeOpencodeForDirectory(
354
+ ctx.directories.projectDirectory,
355
+ )
356
+ if (getClient instanceof Error) {
357
+ throw getClient
358
+ }
359
+ const sessionMessagesResponse = await getClient().session.messages({
360
+ sessionID: sessionId,
361
+ directory: ctx.directories.projectDirectory,
362
+ })
363
+ const sessionMessages = sessionMessagesResponse.data || []
364
+ const emptyUserMessagesWithDefaultModel = sessionMessages.filter((message) => {
365
+ if (message.info.role !== 'user') {
366
+ return false
367
+ }
368
+ const hasNonEmptyTextPart = message.parts.some((part) => {
369
+ if (part.type !== 'text') {
370
+ return false
371
+ }
372
+ return part.text.trim().length > 0
373
+ })
374
+ if (hasNonEmptyTextPart) {
375
+ return false
376
+ }
377
+ return message.info.model.modelID === 'deterministic-v2'
378
+ })
379
+ expect(emptyUserMessagesWithDefaultModel.length).toBe(0)
380
+ },
381
+ 20_000,
382
+ )
383
+ })
@@ -7,6 +7,7 @@ import {
7
7
  } from './queue-advanced-e2e-setup.js'
8
8
  import {
9
9
  waitForBotMessageContaining,
10
+ waitForBotReplyAfterUserMessage,
10
11
  waitForFooterMessage,
11
12
  } from './test-utils.js'
12
13
  import { pendingPermissions } from './session-handler/thread-session-runtime.js'
@@ -140,4 +141,95 @@ describe('queue advanced: typing around permissions', () => {
140
141
  },
141
142
  20_000,
142
143
  )
144
+
145
+ test(
146
+ 'manual thread message dismisses pending permission and sends the new prompt',
147
+ async () => {
148
+ const initialPrompt = 'PERMISSION_TYPING_MARKER dismiss-flow'
149
+
150
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
151
+ content: initialPrompt,
152
+ })
153
+
154
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
155
+ timeout: 4_000,
156
+ predicate: (t) => {
157
+ return t.name === initialPrompt
158
+ },
159
+ })
160
+
161
+ const th = ctx.discord.thread(thread.id)
162
+
163
+ await waitForPendingPermission({
164
+ threadId: thread.id,
165
+ timeoutMs: 4_000,
166
+ })
167
+
168
+ await waitForBotMessageContaining({
169
+ discord: ctx.discord,
170
+ threadId: thread.id,
171
+ userId: TEST_USER_ID,
172
+ text: 'Permission Required',
173
+ timeout: 4_000,
174
+ })
175
+
176
+ await th.user(TEST_USER_ID).sendMessage({
177
+ content: 'Reply with exactly: post-permission-user-message',
178
+ })
179
+
180
+ await waitForBotMessageContaining({
181
+ discord: ctx.discord,
182
+ threadId: thread.id,
183
+ text: 'Permission dismissed - user sent a new message.',
184
+ timeout: 4_000,
185
+ })
186
+
187
+ await waitForBotReplyAfterUserMessage({
188
+ discord: ctx.discord,
189
+ threadId: thread.id,
190
+ userId: TEST_USER_ID,
191
+ userMessageIncludes: 'post-permission-user-message',
192
+ timeout: 4_000,
193
+ })
194
+
195
+ await waitForBotMessageContaining({
196
+ discord: ctx.discord,
197
+ threadId: thread.id,
198
+ userId: TEST_USER_ID,
199
+ text: 'ok',
200
+ afterUserMessageIncludes: 'post-permission-user-message',
201
+ timeout: 4_000,
202
+ })
203
+
204
+ await waitForFooterMessage({
205
+ discord: ctx.discord,
206
+ threadId: thread.id,
207
+ timeout: 4_000,
208
+ afterMessageIncludes: 'ok',
209
+ afterAuthorId: ctx.discord.botUserId,
210
+ })
211
+
212
+ const timeline = await th.text({ showInteractions: true })
213
+ const normalizedTimeline = timeline.replace(
214
+ '⬥ requesting external read permission\n',
215
+ '',
216
+ )
217
+ expect(normalizedTimeline).toMatchInlineSnapshot(`
218
+ "--- from: user (queue-permission-tester)
219
+ PERMISSION_TYPING_MARKER dismiss-flow
220
+ --- from: assistant (TestBot)
221
+ ⚠️ **Permission Required**
222
+ **Type:** \`external_directory\`
223
+ Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)
224
+ **Pattern:** \`/Users/morse/*\`
225
+ _Permission dismissed - user sent a new message._
226
+ --- from: user (queue-permission-tester)
227
+ Reply with exactly: post-permission-user-message
228
+ --- from: assistant (TestBot)
229
+ ⬥ ok
230
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
231
+ `)
232
+ },
233
+ 20_000,
234
+ )
143
235
  })
@@ -7,7 +7,7 @@
7
7
  // Poll timeouts: 4s max, 100ms interval.
8
8
 
9
9
  import fs from 'node:fs'
10
- import net from 'node:net'
10
+
11
11
  import path from 'node:path'
12
12
  import url from 'node:url'
13
13
  import { describe, beforeAll, afterAll, test, expect } from 'vitest'
@@ -36,6 +36,7 @@ import {
36
36
  stopOpencodeServer,
37
37
  } from './opencode.js'
38
38
  import {
39
+ chooseLockPort,
39
40
  cleanupTestSessions,
40
41
  waitForBotMessageContaining,
41
42
  waitForBotReplyAfterUserMessage,
@@ -54,23 +55,7 @@ function createRunDirectories() {
54
55
  return { root, dataDir, projectDirectory }
55
56
  }
56
57
 
57
- function chooseLockPort(): Promise<number> {
58
- return new Promise((resolve, reject) => {
59
- const server = net.createServer()
60
- server.listen(0, () => {
61
- const address = server.address()
62
- if (!address || typeof address === 'string') {
63
- server.close()
64
- reject(new Error('Failed to resolve lock port'))
65
- return
66
- }
67
- const port = address.port
68
- server.close(() => {
69
- resolve(port)
70
- })
71
- })
72
- })
73
- }
58
+
74
59
 
75
60
  function createDiscordJsClient({ restUrl }: { restUrl: string }) {
76
61
  return new Client({
@@ -156,7 +141,7 @@ describe('runtime lifecycle', () => {
156
141
  beforeAll(async () => {
157
142
  testStartTime = Date.now()
158
143
  directories = createRunDirectories()
159
- const lockPort = await chooseLockPort()
144
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID })
160
145
 
161
146
  process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
162
147
  setDataDir(directories.dataDir)
@@ -411,7 +411,7 @@ describe('real-session-task-user-interruption', () => {
411
411
  const firstAssistantId = 'msg_cb9b0ba96001SpPjgzxWPmRuW9'
412
412
  const secondAssistantId = 'msg_cb9b1ae5c001E5G3Ql6aXNpst2'
413
413
 
414
- test('tool-call handoff assistant is not terminal but the resumed reply is', () => {
414
+ test('tool-call handoff assistant is not a natural completion but the resumed reply is', () => {
415
415
  const firstAssistant = getAssistantMessageById({
416
416
  events,
417
417
  sessionId,
@@ -422,6 +422,8 @@ describe('real-session-task-user-interruption', () => {
422
422
  sessionId,
423
423
  messageId: secondAssistantId,
424
424
  })
425
+ // The first message finished with tool-calls — not a natural completion
426
+ // (footer is deferred to session.idle). The second message IS natural.
425
427
  expect(isAssistantMessageNaturalCompletion({ message: firstAssistant })).toBe(false)
426
428
  expect(isAssistantMessageNaturalCompletion({ message: secondAssistant })).toBe(true)
427
429
  })
@@ -558,7 +560,7 @@ describe('real-session-action-buttons', () => {
558
560
  const toolCallAssistantId = 'msg_cb9b55c3b001hXC9qxjVxLMypM'
559
561
  const finalAssistantId = 'msg_cb9b5ddd1001FALqKNM6xW98u6'
560
562
 
561
- test('tool-call handoff assistant is not terminal but final reply is', () => {
563
+ test('tool-call handoff assistant is not a natural completion but final reply is', () => {
562
564
  const toolCallAssistant = getAssistantMessageById({
563
565
  events,
564
566
  sessionId,
@@ -569,6 +571,8 @@ describe('real-session-action-buttons', () => {
569
571
  sessionId,
570
572
  messageId: finalAssistantId,
571
573
  })
574
+ // The tool-call message has finish="tool-calls" — not a natural completion
575
+ // (footer is deferred to session.idle). The final text message IS natural.
572
576
  expect(isAssistantMessageNaturalCompletion({ message: toolCallAssistant })).toBe(false)
573
577
  expect(isAssistantMessageNaturalCompletion({ message: finalAssistant })).toBe(true)
574
578
  })
@@ -116,6 +116,11 @@ export function isAssistantMessageNaturalCompletion({
116
116
  if (message.error) {
117
117
  return false
118
118
  }
119
+ // finish="tool-calls" means the model's last step was tool execution.
120
+ // Mid-turn tool-call steps don't get footers — the footer comes from the
121
+ // final text response (finish="stop") that follows. If the turn ends with
122
+ // only tool-calls and no text follow-up, no footer is emitted. This is
123
+ // acceptable since models almost always follow up with text after tools.
119
124
  return message.finish !== 'tool-calls'
120
125
  }
121
126
 
@@ -123,6 +123,7 @@ import {
123
123
  matchThinkingValue,
124
124
  } from '../thinking-utils.js'
125
125
  import { execAsync } from '../worktrees.js'
126
+
126
127
  import { notifyError } from '../sentry.js'
127
128
  import { createDebouncedProcessFlush } from '../debounced-process-flush.js'
128
129
  import { cancelHtmlActionsForThread } from '../html-actions.js'
@@ -173,7 +174,10 @@ export function getOrCreateRuntime(
173
174
  // Reconcile sdkDirectory: worktree threads transition from pending
174
175
  // (projectDirectory) to ready (worktree path) after runtime creation.
175
176
  if (existing.sdkDirectory !== opts.sdkDirectory) {
176
- existing.sdkDirectory = opts.sdkDirectory
177
+ existing.handleDirectoryChanged({
178
+ oldDirectory: existing.sdkDirectory,
179
+ newDirectory: opts.sdkDirectory,
180
+ })
177
181
  }
178
182
  return existing
179
183
  }
@@ -833,6 +837,44 @@ export class ThreadSessionRuntime {
833
837
  cleanupPendingUiForThread(this.thread.id)
834
838
  }
835
839
 
840
+ // Called when sdkDirectory changes (e.g. worktree becomes ready after
841
+ // /new-worktree in an existing thread). The event listener was subscribed
842
+ // to the old directory's Instance in opencode — events from the new
843
+ // directory's Instance won't reach it. We must reconnect the listener
844
+ // and clear the old session so ensureSession creates a fresh one under
845
+ // the new Instance.
846
+ handleDirectoryChanged({
847
+ oldDirectory,
848
+ newDirectory,
849
+ }: {
850
+ oldDirectory: string
851
+ newDirectory: string
852
+ }): void {
853
+ logger.log(
854
+ `[LISTENER] sdkDirectory changed for thread ${this.threadId}: ${oldDirectory} → ${newDirectory}`,
855
+ )
856
+ this.sdkDirectory = newDirectory
857
+
858
+ // Clear cached session — it was created under the old directory's
859
+ // opencode Instance and can't be reused from the new one.
860
+ threadState.updateThread(this.threadId, (t) => ({
861
+ ...t,
862
+ sessionId: undefined,
863
+ }))
864
+
865
+ // Restart event listener to subscribe under the new directory.
866
+ const currentController = this.state?.listenerController
867
+ if (currentController) {
868
+ currentController.abort(new Error('sdkDirectory changed'))
869
+ threadState.updateThread(this.threadId, (t) => ({
870
+ ...t,
871
+ listenerController: new AbortController(),
872
+ }))
873
+ this.listenerLoopRunning = false
874
+ void this.startEventListener()
875
+ }
876
+ }
877
+
836
878
  handleSharedServerStarted({
837
879
  port,
838
880
  }: {
@@ -2033,6 +2075,7 @@ export class ThreadSessionRuntime {
2033
2075
  `[SESSION IDLE] session became idle sessionId=${sessionId} drainQueue=${shouldDrainQueuedMessages} ${this.formatRunStateForLog()}`,
2034
2076
  )
2035
2077
  await this.persistEventBufferDebounced.flush()
2078
+
2036
2079
  if (!shouldDrainQueuedMessages) {
2037
2080
  return
2038
2081
  }
@@ -3492,7 +3535,7 @@ export class ThreadSessionRuntime {
3492
3535
 
3493
3536
  const client = getOpencodeClient(this.projectDirectory)
3494
3537
 
3495
- // Run git branch, token fetch, and provider list in parallel
3538
+ // Run git branch and token fetch in parallel (fast, no external CLI)
3496
3539
  const [branchResult, contextResult] = await Promise.all([
3497
3540
  errore.tryAsync(() => {
3498
3541
  return execAsync('git symbolic-ref --short HEAD', {