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,299 @@
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
+ import { describe, test, expect } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
8
+ import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage, waitForFooterMessage, waitForMessageById, } from './test-utils.js';
9
+ import { getThreadState } from './session-handler/thread-runtime-state.js';
10
+ import { getSessionModel } from './database.js';
11
+ import { initializeOpencodeForDirectory } from './opencode.js';
12
+ const TEXT_CHANNEL_ID = '200000000000001007';
13
+ function getCustomIdFromInteractionData({ serializedComponents, prefix, }) {
14
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+ const customIdRegex = new RegExp(`\"custom_id\"\\s*:\\s*\"(${escapedPrefix}[^\"]+)\"`);
16
+ const match = serializedComponents.match(customIdRegex);
17
+ if (!match?.[1]) {
18
+ throw new Error(`Could not find custom_id with prefix ${prefix} in components: ${serializedComponents}`);
19
+ }
20
+ return match[1];
21
+ }
22
+ async function waitForMessageComponentsWithCustomId({ discord, threadId, messageId, customIdPrefix, timeoutMs, }) {
23
+ const start = Date.now();
24
+ while (Date.now() - start < timeoutMs) {
25
+ const message = await waitForMessageById({
26
+ discord,
27
+ threadId,
28
+ messageId,
29
+ timeout: 1_000,
30
+ });
31
+ const serializedComponents = JSON.stringify(message.components);
32
+ if (serializedComponents.includes(customIdPrefix)) {
33
+ return serializedComponents;
34
+ }
35
+ await new Promise((resolve) => {
36
+ setTimeout(resolve, 50);
37
+ });
38
+ }
39
+ throw new Error(`Timed out waiting for custom_id prefix ${customIdPrefix} in message ${messageId}`);
40
+ }
41
+ async function waitForInteractionMessage({ getInteraction, interactionId, timeoutMs, }) {
42
+ const start = Date.now();
43
+ while (Date.now() - start < timeoutMs) {
44
+ const response = await getInteraction(interactionId);
45
+ if (response?.messageId) {
46
+ return {
47
+ messageId: response.messageId,
48
+ data: response.data || '',
49
+ };
50
+ }
51
+ await new Promise((resolve) => {
52
+ setTimeout(resolve, 50);
53
+ });
54
+ }
55
+ throw new Error(`Timed out waiting for interaction message ${interactionId}`);
56
+ }
57
+ describe('queue advanced: /model with interrupt recovery', () => {
58
+ const ctx = setupQueueAdvancedSuite({
59
+ channelId: TEXT_CHANNEL_ID,
60
+ channelName: 'qa-model-switch-e2e',
61
+ dirName: 'qa-model-switch-e2e',
62
+ username: 'queue-model-switch-tester',
63
+ });
64
+ test('session model selected in /model survives interrupt-plugin resume path', async () => {
65
+ const buildAgentDir = path.join(ctx.directories.projectDirectory, '.opencode', 'agent');
66
+ fs.mkdirSync(buildAgentDir, { recursive: true });
67
+ fs.writeFileSync(path.join(buildAgentDir, 'build.md'), [
68
+ '---',
69
+ 'name: build',
70
+ 'description: Default build agent for deterministic model tests',
71
+ 'model: deterministic-provider/deterministic-v2',
72
+ '---',
73
+ '',
74
+ 'You are the default build agent.',
75
+ '',
76
+ ].join('\n'));
77
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
78
+ content: 'Reply with exactly: model-switcher-setup',
79
+ });
80
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
81
+ timeout: 4_000,
82
+ predicate: (t) => {
83
+ return t.name === 'Reply with exactly: model-switcher-setup';
84
+ },
85
+ });
86
+ const th = ctx.discord.thread(thread.id);
87
+ await th.waitForBotReply({ timeout: 4_000 });
88
+ await waitForFooterMessage({
89
+ discord: ctx.discord,
90
+ threadId: thread.id,
91
+ timeout: 4_000,
92
+ });
93
+ const modelCommand = await th.user(TEST_USER_ID).runSlashCommand({
94
+ name: 'model',
95
+ });
96
+ await th.waitForInteractionAck({
97
+ interactionId: modelCommand.id,
98
+ timeout: 4_000,
99
+ });
100
+ const providerStep = await waitForInteractionMessage({
101
+ getInteraction: (interactionId) => {
102
+ return th.getInteractionResponse(interactionId);
103
+ },
104
+ interactionId: modelCommand.id,
105
+ timeoutMs: 4_000,
106
+ });
107
+ const providerCustomId = getCustomIdFromInteractionData({
108
+ serializedComponents: await waitForMessageComponentsWithCustomId({
109
+ discord: ctx.discord,
110
+ threadId: thread.id,
111
+ messageId: providerStep.messageId,
112
+ customIdPrefix: 'model_provider:',
113
+ timeoutMs: 4_000,
114
+ }),
115
+ prefix: 'model_provider:',
116
+ });
117
+ const providerSelect = await th.user(TEST_USER_ID).selectMenu({
118
+ messageId: providerStep.messageId,
119
+ customId: providerCustomId,
120
+ values: ['deterministic-provider'],
121
+ });
122
+ await th.waitForInteractionAck({
123
+ interactionId: providerSelect.id,
124
+ timeout: 4_000,
125
+ });
126
+ const modelStep = await waitForInteractionMessage({
127
+ getInteraction: (interactionId) => {
128
+ return th.getInteractionResponse(interactionId);
129
+ },
130
+ interactionId: providerSelect.id,
131
+ timeoutMs: 4_000,
132
+ });
133
+ const modelCustomId = getCustomIdFromInteractionData({
134
+ serializedComponents: await waitForMessageComponentsWithCustomId({
135
+ discord: ctx.discord,
136
+ threadId: thread.id,
137
+ messageId: modelStep.messageId,
138
+ customIdPrefix: 'model_select:',
139
+ timeoutMs: 4_000,
140
+ }),
141
+ prefix: 'model_select:',
142
+ });
143
+ const modelSelect = await th.user(TEST_USER_ID).selectMenu({
144
+ messageId: modelStep.messageId,
145
+ customId: modelCustomId,
146
+ values: ['deterministic-v3'],
147
+ });
148
+ await th.waitForInteractionAck({
149
+ interactionId: modelSelect.id,
150
+ timeout: 4_000,
151
+ });
152
+ const maybeVariantOrScopeStep = await waitForInteractionMessage({
153
+ getInteraction: (interactionId) => {
154
+ return th.getInteractionResponse(interactionId);
155
+ },
156
+ interactionId: modelSelect.id,
157
+ timeoutMs: 4_000,
158
+ });
159
+ const maybeVariantOrScopeMessage = await waitForMessageById({
160
+ discord: ctx.discord,
161
+ threadId: thread.id,
162
+ messageId: maybeVariantOrScopeStep.messageId,
163
+ timeout: 4_000,
164
+ });
165
+ const maybeVariantOrScopeComponents = JSON.stringify(maybeVariantOrScopeMessage.components);
166
+ const scopeStep = maybeVariantOrScopeComponents.includes('model_variant:')
167
+ ? await (async () => {
168
+ const variantCustomId = getCustomIdFromInteractionData({
169
+ serializedComponents: maybeVariantOrScopeComponents,
170
+ prefix: 'model_variant:',
171
+ });
172
+ const variantSelect = await th.user(TEST_USER_ID).selectMenu({
173
+ messageId: maybeVariantOrScopeStep.messageId,
174
+ customId: variantCustomId,
175
+ values: ['__none__'],
176
+ });
177
+ await th.waitForInteractionAck({
178
+ interactionId: variantSelect.id,
179
+ timeout: 4_000,
180
+ });
181
+ return waitForInteractionMessage({
182
+ getInteraction: (interactionId) => {
183
+ return th.getInteractionResponse(interactionId);
184
+ },
185
+ interactionId: variantSelect.id,
186
+ timeoutMs: 4_000,
187
+ });
188
+ })()
189
+ : maybeVariantOrScopeStep;
190
+ const scopeCustomId = getCustomIdFromInteractionData({
191
+ serializedComponents: await waitForMessageComponentsWithCustomId({
192
+ discord: ctx.discord,
193
+ threadId: thread.id,
194
+ messageId: scopeStep.messageId,
195
+ customIdPrefix: 'model_scope:',
196
+ timeoutMs: 4_000,
197
+ }),
198
+ prefix: 'model_scope:',
199
+ });
200
+ const scopeSelect = await th.user(TEST_USER_ID).selectMenu({
201
+ messageId: scopeStep.messageId,
202
+ customId: scopeCustomId,
203
+ values: ['session'],
204
+ });
205
+ await th.waitForInteractionAck({
206
+ interactionId: scopeSelect.id,
207
+ timeout: 4_000,
208
+ });
209
+ const sessionId = getThreadState(thread.id)?.sessionId;
210
+ expect(sessionId).toBeDefined();
211
+ if (!sessionId) {
212
+ throw new Error('Expected session id to be present after /model selection');
213
+ }
214
+ const sessionModel = await getSessionModel(sessionId);
215
+ expect(sessionModel?.modelId).toBe('deterministic-provider/deterministic-v3');
216
+ await th.user(TEST_USER_ID).sendMessage({
217
+ content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
218
+ });
219
+ await waitForBotMessageContaining({
220
+ discord: ctx.discord,
221
+ threadId: thread.id,
222
+ userId: TEST_USER_ID,
223
+ text: 'starting sleep',
224
+ afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
225
+ timeout: 4_000,
226
+ });
227
+ await th.user(TEST_USER_ID).sendMessage({
228
+ content: 'Reply with exactly: model-switcher-followup',
229
+ });
230
+ await waitForBotReplyAfterUserMessage({
231
+ discord: ctx.discord,
232
+ threadId: thread.id,
233
+ userId: TEST_USER_ID,
234
+ userMessageIncludes: 'model-switcher-followup',
235
+ timeout: 8_000,
236
+ });
237
+ const finalMessages = await waitForFooterMessage({
238
+ discord: ctx.discord,
239
+ threadId: thread.id,
240
+ timeout: 8_000,
241
+ afterMessageIncludes: 'model-switcher-followup',
242
+ afterAuthorId: TEST_USER_ID,
243
+ });
244
+ const footer = [...finalMessages].reverse().find((message) => {
245
+ return message.author.id === ctx.discord.botUserId
246
+ && message.content.startsWith('*')
247
+ && message.content.includes('⋅');
248
+ });
249
+ expect(await th.text()).toMatchInlineSnapshot(`
250
+ "--- from: user (queue-model-switch-tester)
251
+ Reply with exactly: model-switcher-setup
252
+ --- from: assistant (TestBot)
253
+ ⬥ ok
254
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
255
+ Model set for this session:
256
+ **Deterministic Provider** / **deterministic-v3**
257
+ \`deterministic-provider/deterministic-v3\`
258
+ _Restarting current request with new model..._
259
+ _Tip: create [agent .md files](https://github.com/remorses/kimaki/blob/main/docs/model-switching.md) in .opencode/agent/ for one-command model switching_
260
+ --- from: user (queue-model-switch-tester)
261
+ PLUGIN_TIMEOUT_SLEEP_MARKER
262
+ --- from: assistant (TestBot)
263
+ ⬥ ok
264
+ ⬥ starting sleep 100
265
+ --- from: user (queue-model-switch-tester)
266
+ Reply with exactly: model-switcher-followup
267
+ --- from: assistant (TestBot)
268
+ ⬥ ok
269
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
270
+ `);
271
+ expect(footer).toBeDefined();
272
+ expect(footer?.content).toContain('deterministic-v3');
273
+ const getClient = await initializeOpencodeForDirectory(ctx.directories.projectDirectory);
274
+ if (getClient instanceof Error) {
275
+ throw getClient;
276
+ }
277
+ const sessionMessagesResponse = await getClient().session.messages({
278
+ sessionID: sessionId,
279
+ directory: ctx.directories.projectDirectory,
280
+ });
281
+ const sessionMessages = sessionMessagesResponse.data || [];
282
+ const emptyUserMessagesWithDefaultModel = sessionMessages.filter((message) => {
283
+ if (message.info.role !== 'user') {
284
+ return false;
285
+ }
286
+ const hasNonEmptyTextPart = message.parts.some((part) => {
287
+ if (part.type !== 'text') {
288
+ return false;
289
+ }
290
+ return part.text.trim().length > 0;
291
+ });
292
+ if (hasNonEmptyTextPart) {
293
+ return false;
294
+ }
295
+ return message.info.model.modelID === 'deterministic-v2';
296
+ });
297
+ expect(emptyUserMessagesWithDefaultModel.length).toBe(0);
298
+ }, 20_000);
299
+ });
@@ -1,7 +1,7 @@
1
1
  // E2e tests for typing indicator behavior around permission prompts.
2
2
  import { describe, test, expect } from 'vitest';
3
3
  import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
4
- import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
4
+ import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage, waitForFooterMessage, } from './test-utils.js';
5
5
  import { pendingPermissions } from './session-handler/thread-session-runtime.js';
6
6
  const TEXT_CHANNEL_ID = '200000000000001005';
7
7
  async function waitForPendingPermission({ threadId, timeoutMs, }) {
@@ -103,4 +103,76 @@ describe('queue advanced: typing around permissions', () => {
103
103
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
104
104
  `);
105
105
  }, 20_000);
106
+ test('manual thread message dismisses pending permission and sends the new prompt', async () => {
107
+ const initialPrompt = 'PERMISSION_TYPING_MARKER dismiss-flow';
108
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
109
+ content: initialPrompt,
110
+ });
111
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
112
+ timeout: 4_000,
113
+ predicate: (t) => {
114
+ return t.name === initialPrompt;
115
+ },
116
+ });
117
+ const th = ctx.discord.thread(thread.id);
118
+ await waitForPendingPermission({
119
+ threadId: thread.id,
120
+ timeoutMs: 4_000,
121
+ });
122
+ await waitForBotMessageContaining({
123
+ discord: ctx.discord,
124
+ threadId: thread.id,
125
+ userId: TEST_USER_ID,
126
+ text: 'Permission Required',
127
+ timeout: 4_000,
128
+ });
129
+ await th.user(TEST_USER_ID).sendMessage({
130
+ content: 'Reply with exactly: post-permission-user-message',
131
+ });
132
+ await waitForBotMessageContaining({
133
+ discord: ctx.discord,
134
+ threadId: thread.id,
135
+ text: 'Permission dismissed - user sent a new message.',
136
+ timeout: 4_000,
137
+ });
138
+ await waitForBotReplyAfterUserMessage({
139
+ discord: ctx.discord,
140
+ threadId: thread.id,
141
+ userId: TEST_USER_ID,
142
+ userMessageIncludes: 'post-permission-user-message',
143
+ timeout: 4_000,
144
+ });
145
+ await waitForBotMessageContaining({
146
+ discord: ctx.discord,
147
+ threadId: thread.id,
148
+ userId: TEST_USER_ID,
149
+ text: 'ok',
150
+ afterUserMessageIncludes: 'post-permission-user-message',
151
+ timeout: 4_000,
152
+ });
153
+ await waitForFooterMessage({
154
+ discord: ctx.discord,
155
+ threadId: thread.id,
156
+ timeout: 4_000,
157
+ afterMessageIncludes: 'ok',
158
+ afterAuthorId: ctx.discord.botUserId,
159
+ });
160
+ const timeline = await th.text({ showInteractions: true });
161
+ const normalizedTimeline = timeline.replace('⬥ requesting external read permission\n', '');
162
+ expect(normalizedTimeline).toMatchInlineSnapshot(`
163
+ "--- from: user (queue-permission-tester)
164
+ PERMISSION_TYPING_MARKER dismiss-flow
165
+ --- from: assistant (TestBot)
166
+ ⚠️ **Permission Required**
167
+ **Type:** \`external_directory\`
168
+ Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)
169
+ **Pattern:** \`/Users/morse/*\`
170
+ _Permission dismissed - user sent a new message._
171
+ --- from: user (queue-permission-tester)
172
+ Reply with exactly: post-permission-user-message
173
+ --- from: assistant (TestBot)
174
+ ⬥ ok
175
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
176
+ `);
177
+ }, 20_000);
106
178
  });
@@ -6,7 +6,6 @@
6
6
  // Uses opencode-deterministic-provider (no real LLM calls).
7
7
  // Poll timeouts: 4s max, 100ms interval.
8
8
  import fs from 'node:fs';
9
- import net from 'node:net';
10
9
  import path from 'node:path';
11
10
  import url from 'node:url';
12
11
  import { describe, beforeAll, afterAll, test, expect } from 'vitest';
@@ -20,7 +19,7 @@ import { getRuntime } from './session-handler/thread-session-runtime.js';
20
19
  import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
21
20
  import { startHranaServer, stopHranaServer } from './hrana-server.js';
22
21
  import { initializeOpencodeForDirectory, restartOpencodeServer, stopOpencodeServer, } from './opencode.js';
23
- import { cleanupTestSessions, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
22
+ import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
24
23
  const TEST_USER_ID = '200000000000000888';
25
24
  const TEXT_CHANNEL_ID = '200000000000000889';
26
25
  function createRunDirectories() {
@@ -31,23 +30,6 @@ function createRunDirectories() {
31
30
  fs.mkdirSync(projectDirectory, { recursive: true });
32
31
  return { root, dataDir, projectDirectory };
33
32
  }
34
- function chooseLockPort() {
35
- return new Promise((resolve, reject) => {
36
- const server = net.createServer();
37
- server.listen(0, () => {
38
- const address = server.address();
39
- if (!address || typeof address === 'string') {
40
- server.close();
41
- reject(new Error('Failed to resolve lock port'));
42
- return;
43
- }
44
- const port = address.port;
45
- server.close(() => {
46
- resolve(port);
47
- });
48
- });
49
- });
50
- }
51
33
  function createDiscordJsClient({ restUrl }) {
52
34
  return new Client({
53
35
  intents: [
@@ -127,7 +109,7 @@ describe('runtime lifecycle', () => {
127
109
  beforeAll(async () => {
128
110
  testStartTime = Date.now();
129
111
  directories = createRunDirectories();
130
- const lockPort = await chooseLockPort();
112
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
131
113
  process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
132
114
  setDataDir(directories.dataDir);
133
115
  previousDefaultVerbosity = store.getState().defaultVerbosity;
@@ -71,6 +71,11 @@ export function isAssistantMessageNaturalCompletion({ message, }) {
71
71
  if (message.error) {
72
72
  return false;
73
73
  }
74
+ // finish="tool-calls" means the model's last step was tool execution.
75
+ // Mid-turn tool-call steps don't get footers — the footer comes from the
76
+ // final text response (finish="stop") that follows. If the turn ends with
77
+ // only tool-calls and no text follow-up, no footer is emitted. This is
78
+ // acceptable since models almost always follow up with text after tools.
74
79
  return message.finish !== 'tool-calls';
75
80
  }
76
81
  export function hasAssistantMessageCompletedBefore({ events, sessionId, messageId, upToIndex, }) {
@@ -346,7 +346,7 @@ describe('real-session-task-user-interruption', () => {
346
346
  const childSessionId = 'ses_3464f3a1dffeBBD0d15EqnGjAh';
347
347
  const firstAssistantId = 'msg_cb9b0ba96001SpPjgzxWPmRuW9';
348
348
  const secondAssistantId = 'msg_cb9b1ae5c001E5G3Ql6aXNpst2';
349
- test('tool-call handoff assistant is not terminal but the resumed reply is', () => {
349
+ test('tool-call handoff assistant is not a natural completion but the resumed reply is', () => {
350
350
  const firstAssistant = getAssistantMessageById({
351
351
  events,
352
352
  sessionId,
@@ -357,6 +357,8 @@ describe('real-session-task-user-interruption', () => {
357
357
  sessionId,
358
358
  messageId: secondAssistantId,
359
359
  });
360
+ // The first message finished with tool-calls — not a natural completion
361
+ // (footer is deferred to session.idle). The second message IS natural.
360
362
  expect(isAssistantMessageNaturalCompletion({ message: firstAssistant })).toBe(false);
361
363
  expect(isAssistantMessageNaturalCompletion({ message: secondAssistant })).toBe(true);
362
364
  });
@@ -482,7 +484,7 @@ describe('real-session-action-buttons', () => {
482
484
  const sessionId = getSessionId(events);
483
485
  const toolCallAssistantId = 'msg_cb9b55c3b001hXC9qxjVxLMypM';
484
486
  const finalAssistantId = 'msg_cb9b5ddd1001FALqKNM6xW98u6';
485
- test('tool-call handoff assistant is not terminal but final reply is', () => {
487
+ test('tool-call handoff assistant is not a natural completion but final reply is', () => {
486
488
  const toolCallAssistant = getAssistantMessageById({
487
489
  events,
488
490
  sessionId,
@@ -493,6 +495,8 @@ describe('real-session-action-buttons', () => {
493
495
  sessionId,
494
496
  messageId: finalAssistantId,
495
497
  });
498
+ // The tool-call message has finish="tool-calls" — not a natural completion
499
+ // (footer is deferred to session.idle). The final text message IS natural.
496
500
  expect(isAssistantMessageNaturalCompletion({ message: toolCallAssistant })).toBe(false);
497
501
  expect(isAssistantMessageNaturalCompletion({ message: finalAssistant })).toBe(true);
498
502
  });
@@ -62,7 +62,10 @@ export function getOrCreateRuntime(opts) {
62
62
  // Reconcile sdkDirectory: worktree threads transition from pending
63
63
  // (projectDirectory) to ready (worktree path) after runtime creation.
64
64
  if (existing.sdkDirectory !== opts.sdkDirectory) {
65
- existing.sdkDirectory = opts.sdkDirectory;
65
+ existing.handleDirectoryChanged({
66
+ oldDirectory: existing.sdkDirectory,
67
+ newDirectory: opts.sdkDirectory,
68
+ });
66
69
  }
67
70
  return existing;
68
71
  }
@@ -524,6 +527,33 @@ export class ThreadSessionRuntime {
524
527
  // action buttons, file uploads, html actions).
525
528
  cleanupPendingUiForThread(this.thread.id);
526
529
  }
530
+ // Called when sdkDirectory changes (e.g. worktree becomes ready after
531
+ // /new-worktree in an existing thread). The event listener was subscribed
532
+ // to the old directory's Instance in opencode — events from the new
533
+ // directory's Instance won't reach it. We must reconnect the listener
534
+ // and clear the old session so ensureSession creates a fresh one under
535
+ // the new Instance.
536
+ handleDirectoryChanged({ oldDirectory, newDirectory, }) {
537
+ logger.log(`[LISTENER] sdkDirectory changed for thread ${this.threadId}: ${oldDirectory} → ${newDirectory}`);
538
+ this.sdkDirectory = newDirectory;
539
+ // Clear cached session — it was created under the old directory's
540
+ // opencode Instance and can't be reused from the new one.
541
+ threadState.updateThread(this.threadId, (t) => ({
542
+ ...t,
543
+ sessionId: undefined,
544
+ }));
545
+ // Restart event listener to subscribe under the new directory.
546
+ const currentController = this.state?.listenerController;
547
+ if (currentController) {
548
+ currentController.abort(new Error('sdkDirectory changed'));
549
+ threadState.updateThread(this.threadId, (t) => ({
550
+ ...t,
551
+ listenerController: new AbortController(),
552
+ }));
553
+ this.listenerLoopRunning = false;
554
+ void this.startEventListener();
555
+ }
556
+ }
527
557
  handleSharedServerStarted({ port, }) {
528
558
  const currentController = this.state?.listenerController;
529
559
  if (!currentController) {
@@ -2656,7 +2686,7 @@ export class ThreadSessionRuntime {
2656
2686
  let contextInfo = '';
2657
2687
  const folderName = path.basename(this.sdkDirectory);
2658
2688
  const client = getOpencodeClient(this.projectDirectory);
2659
- // Run git branch, token fetch, and provider list in parallel
2689
+ // Run git branch and token fetch in parallel (fast, no external CLI)
2660
2690
  const [branchResult, contextResult] = await Promise.all([
2661
2691
  errore.tryAsync(() => {
2662
2692
  return execAsync('git symbolic-ref --short HEAD', {