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,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 =
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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', {
|