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.
- package/dist/adapter-rest-boundary.test.js +34 -0
- package/dist/agent-model.e2e.test.js +2 -20
- package/dist/cli.js +50 -13
- package/dist/commands/channel-ref.js +16 -0
- package/dist/commands/diff.js +20 -85
- package/dist/commands/merge-worktree.js +5 -17
- package/dist/commands/new-worktree.js +5 -9
- package/dist/commands/permissions.js +77 -11
- package/dist/commands/resume.js +5 -9
- package/dist/commands/screenshare.js +295 -0
- package/dist/commands/session.js +6 -17
- package/dist/critique-utils.js +95 -0
- package/dist/diff-patch-plugin.js +314 -0
- package/dist/discord-bot.js +19 -14
- package/dist/discord-js-import-boundary.test.js +62 -0
- package/dist/discord-utils.js +44 -0
- package/dist/event-stream-real-capture.e2e.test.js +2 -20
- package/dist/gateway-proxy.e2e.test.js +2 -5
- package/dist/generated/cloudflare/browser.js +17 -0
- package/dist/generated/cloudflare/client.js +34 -0
- package/dist/generated/cloudflare/commonInputTypes.js +10 -0
- package/dist/generated/cloudflare/enums.js +48 -0
- package/dist/generated/cloudflare/internal/class.js +47 -0
- package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
- package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
- package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
- package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
- package/dist/generated/cloudflare/models/channel_agents.js +1 -0
- package/dist/generated/cloudflare/models/channel_directories.js +1 -0
- package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
- package/dist/generated/cloudflare/models/channel_models.js +1 -0
- package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
- package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
- package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
- package/dist/generated/cloudflare/models/global_models.js +1 -0
- package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
- package/dist/generated/cloudflare/models/part_messages.js +1 -0
- package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
- package/dist/generated/cloudflare/models/session_agents.js +1 -0
- package/dist/generated/cloudflare/models/session_events.js +1 -0
- package/dist/generated/cloudflare/models/session_models.js +1 -0
- package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
- package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
- package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
- package/dist/generated/cloudflare/models.js +1 -0
- package/dist/generated/node/browser.js +17 -0
- package/dist/generated/node/client.js +37 -0
- package/dist/generated/node/commonInputTypes.js +10 -0
- package/dist/generated/node/enums.js +48 -0
- package/dist/generated/node/internal/class.js +49 -0
- package/dist/generated/node/internal/prismaNamespace.js +252 -0
- package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/node/models/bot_api_keys.js +1 -0
- package/dist/generated/node/models/bot_tokens.js +1 -0
- package/dist/generated/node/models/channel_agents.js +1 -0
- package/dist/generated/node/models/channel_directories.js +1 -0
- package/dist/generated/node/models/channel_mention_mode.js +1 -0
- package/dist/generated/node/models/channel_models.js +1 -0
- package/dist/generated/node/models/channel_verbosity.js +1 -0
- package/dist/generated/node/models/channel_worktrees.js +1 -0
- package/dist/generated/node/models/forum_sync_configs.js +1 -0
- package/dist/generated/node/models/global_models.js +1 -0
- package/dist/generated/node/models/ipc_requests.js +1 -0
- package/dist/generated/node/models/part_messages.js +1 -0
- package/dist/generated/node/models/scheduled_tasks.js +1 -0
- package/dist/generated/node/models/session_agents.js +1 -0
- package/dist/generated/node/models/session_events.js +1 -0
- package/dist/generated/node/models/session_models.js +1 -0
- package/dist/generated/node/models/session_start_sources.js +1 -0
- package/dist/generated/node/models/thread_sessions.js +1 -0
- package/dist/generated/node/models/thread_worktrees.js +1 -0
- package/dist/generated/node/models.js +1 -0
- package/dist/interaction-handler.js +10 -0
- package/dist/kimaki-digital-twin.e2e.test.js +2 -20
- package/dist/message-flags-boundary.test.js +54 -0
- package/dist/message-formatting.js +3 -62
- package/dist/onboarding-tutorial-plugin.js +1 -1
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +19 -1
- package/dist/opencode-interrupt-plugin.test.js +0 -5
- package/dist/opencode-plugin-loading.e2e.test.js +9 -20
- package/dist/opencode-plugin.js +4 -4
- package/dist/opencode.js +150 -27
- package/dist/patch-text-parser.js +97 -0
- package/dist/platform/components-v2.js +20 -0
- package/dist/platform/discord-adapter.js +1440 -0
- package/dist/platform/discord-routes.js +31 -0
- package/dist/platform/message-flags.js +8 -0
- package/dist/platform/platform-value.js +41 -0
- package/dist/platform/slack-adapter.js +872 -0
- package/dist/platform/slack-markdown.js +169 -0
- package/dist/platform/types.js +4 -0
- package/dist/queue-advanced-e2e-setup.js +265 -0
- package/dist/queue-advanced-footer.e2e.test.js +173 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
- package/dist/runtime-lifecycle.e2e.test.js +2 -20
- package/dist/session-handler/event-stream-state.js +5 -0
- package/dist/session-handler/event-stream-state.test.js +6 -2
- package/dist/session-handler/thread-session-runtime.js +32 -2
- package/dist/system-message.js +26 -23
- package/dist/test-utils.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +2 -20
- package/dist/utils.js +3 -1
- package/dist/voice-message.e2e.test.js +2 -20
- package/dist/voice.js +122 -9
- package/dist/voice.test.js +17 -2
- package/dist/websockify.js +69 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/package.json +4 -2
- package/skills/critique/SKILL.md +17 -0
- package/skills/egaki/SKILL.md +35 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/goke/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +21 -2
- package/skills/playwriter/SKILL.md +1 -1
- package/skills/x-articles/SKILL.md +554 -0
- package/src/agent-model.e2e.test.ts +4 -19
- package/src/cli.ts +60 -13
- package/src/commands/diff.ts +25 -99
- package/src/commands/merge-worktree.ts +5 -21
- package/src/commands/new-worktree.ts +5 -11
- package/src/commands/permissions.ts +100 -15
- package/src/commands/resume.ts +5 -12
- package/src/commands/screenshare.ts +354 -0
- package/src/commands/session.ts +6 -23
- package/src/critique-utils.ts +139 -0
- package/src/discord-bot.ts +20 -15
- package/src/discord-utils.ts +53 -0
- package/src/event-stream-real-capture.e2e.test.ts +4 -20
- package/src/gateway-proxy.e2e.test.ts +2 -5
- package/src/interaction-handler.ts +15 -0
- package/src/kimaki-digital-twin.e2e.test.ts +2 -21
- package/src/message-formatting.ts +3 -68
- package/src/onboarding-tutorial-plugin.ts +1 -1
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +0 -5
- package/src/opencode-interrupt-plugin.ts +34 -1
- package/src/opencode-plugin-loading.e2e.test.ts +25 -35
- package/src/opencode-plugin.ts +5 -4
- package/src/opencode.ts +199 -32
- package/src/patch-text-parser.ts +107 -0
- package/src/queue-advanced-e2e-setup.ts +273 -0
- package/src/queue-advanced-footer.e2e.test.ts +211 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
- package/src/runtime-lifecycle.e2e.test.ts +4 -19
- package/src/session-handler/event-stream-state.test.ts +6 -2
- package/src/session-handler/event-stream-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +45 -2
- package/src/system-message.ts +26 -23
- package/src/test-utils.ts +17 -0
- package/src/thread-message-queue.e2e.test.ts +2 -20
- package/src/utils.ts +3 -1
- package/src/voice-message.e2e.test.ts +3 -20
- package/src/voice.test.ts +26 -2
- package/src/voice.ts +147 -9
- package/src/websockify.ts +101 -0
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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', {
|