kimaki 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +33 -33
- package/dist/anthropic-account-identity.js +1 -1
- package/dist/anthropic-auth-plugin.js +5 -5
- package/dist/anthropic-auth-plugin.test.js +30 -2
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/bin.js +7 -3
- package/dist/bot-lifecycle.test.js +206 -0
- package/dist/cache-drift-plugin.js +25 -18
- package/dist/cache-rewrite.js +39 -0
- package/dist/cache-rewrite.test.js +41 -0
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +118 -36
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli-parsing.test.js +3 -3
- package/dist/cli-runner.js +11 -1
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +6 -4
- package/dist/commands/ask-question.js +72 -23
- package/dist/commands/queue.js +84 -36
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +149 -6
- package/dist/db.js +34 -0
- package/dist/db.test.js +199 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +220 -146
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-directory-permissions.e2e.test.js +3 -3
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/forum-sync/watchers.js +15 -0
- package/dist/gateway-proxy.e2e.test.js +9 -9
- package/dist/guild-categories.test.js +225 -0
- package/dist/hrana-server.js +68 -14
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/markdown.js +164 -102
- package/dist/markdown.test.js +88 -20
- package/dist/message-formatting.js +29 -18
- package/dist/message-formatting.test.js +111 -3
- package/dist/opencode.js +27 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-resume.e2e.test.js +100 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/question-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +3 -3
- package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
- package/dist/queue-advanced-e2e-setup.js +63 -0
- package/dist/queue-advanced-footer.e2e.test.js +18 -18
- package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +4 -4
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
- package/dist/queue-interrupt-drain.e2e.test.js +10 -10
- package/dist/queue-question-select-drain.e2e.test.js +5 -5
- package/dist/runtime-lifecycle.e2e.test.js +95 -11
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +240 -6
- package/dist/session-handler/event-stream-state.test.js +1116 -130
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +499 -89
- package/dist/sleep.e2e.test.js +7 -7
- package/dist/store.js +1 -1
- package/dist/system-message.js +64 -23
- package/dist/system-message.test.js +69 -23
- package/dist/task-id-plugin.js +16 -0
- package/dist/task-runner.js +7 -0
- package/dist/task-schedule.test.js +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +30 -31
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-message.e2e.test.js +64 -26
- package/dist/voice-routing-input.e2e.test.js +4 -4
- package/dist/voice-routing-question.e2e.test.js +4 -4
- package/dist/voice.js +107 -21
- package/dist/voice.test.js +52 -1
- package/dist/wait-session.js +24 -2
- package/dist/worktree-lifecycle.e2e.test.js +8 -8
- package/package.json +7 -7
- package/skills/egaki/SKILL.md +14 -0
- package/skills/holocron/SKILL.md +125 -42
- package/skills/opensrc/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +53 -172
- package/skills/strada/SKILL.md +61 -232
- package/src/agent-model.e2e.test.ts +33 -33
- package/src/anthropic-account-identity.ts +1 -1
- package/src/anthropic-auth-plugin.test.ts +37 -2
- package/src/anthropic-auth-plugin.ts +9 -7
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/bin.ts +7 -3
- package/src/bot-lifecycle.test.ts +252 -0
- package/src/cache-drift-plugin.ts +34 -24
- package/src/cache-rewrite.test.ts +49 -0
- package/src/cache-rewrite.ts +96 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +167 -38
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli-parsing.test.ts +3 -3
- package/src/cli-runner.ts +11 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +8 -6
- package/src/commands/ask-question.ts +88 -25
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +111 -45
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +219 -6
- package/src/db.test.ts +236 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +272 -185
- package/src/discord-command-registration.ts +3 -1
- package/src/external-directory-permissions.e2e.test.ts +3 -3
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/forum-sync/watchers.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +9 -9
- package/src/guild-categories.test.ts +260 -0
- package/src/hrana-server.ts +96 -16
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/markdown.test.ts +108 -22
- package/src/markdown.ts +216 -103
- package/src/message-formatting.test.ts +129 -2
- package/src/message-formatting.ts +28 -14
- package/src/opencode.ts +28 -13
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-resume.e2e.test.ts +121 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/question-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +3 -3
- package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
- package/src/queue-advanced-e2e-setup.ts +65 -0
- package/src/queue-advanced-footer.e2e.test.ts +18 -18
- package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +4 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
- package/src/queue-interrupt-drain.e2e.test.ts +10 -10
- package/src/queue-question-select-drain.e2e.test.ts +5 -5
- package/src/runtime-lifecycle.e2e.test.ts +104 -10
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +1184 -133
- package/src/session-handler/event-stream-state.ts +322 -8
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +597 -95
- package/src/sleep.e2e.test.ts +7 -7
- package/src/store.ts +2 -2
- package/src/system-message.test.ts +70 -23
- package/src/system-message.ts +67 -22
- package/src/task-id-plugin.ts +17 -0
- package/src/task-runner.ts +8 -0
- package/src/task-schedule.test.ts +1 -1
- package/src/test-utils.ts +5 -3
- package/src/thread-message-queue.e2e.test.ts +30 -31
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-message.e2e.test.ts +70 -26
- package/src/voice-routing-input.e2e.test.ts +4 -4
- package/src/voice-routing-question.e2e.test.ts +4 -4
- package/src/voice.test.ts +56 -0
- package/src/voice.ts +135 -25
- package/src/wait-session.ts +25 -2
- package/src/worktree-lifecycle.e2e.test.ts +8 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { afterEach, describe, test, expect } from 'vitest';
|
|
4
|
-
import { asDiscordQuote, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
|
|
4
|
+
import { asDiscordQuote, asSystemLine, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, quotedTextFitsOneDiscordMessage, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
|
|
5
5
|
import { getDataDir } from './config.js';
|
|
6
6
|
describe('formatPart', () => {
|
|
7
7
|
test('callout text is returned without a diamond prefix', () => {
|
|
@@ -42,6 +42,39 @@ describe('formatPart', () => {
|
|
|
42
42
|
Done."
|
|
43
43
|
`);
|
|
44
44
|
});
|
|
45
|
+
test('synthetic user context stays hidden', () => {
|
|
46
|
+
const part = {
|
|
47
|
+
id: 'prt_branch',
|
|
48
|
+
type: 'text',
|
|
49
|
+
sessionID: 'ses_test',
|
|
50
|
+
messageID: 'msg_user',
|
|
51
|
+
text: '\n[current git branch is main]\n',
|
|
52
|
+
synthetic: true,
|
|
53
|
+
};
|
|
54
|
+
expect(formatPart(part)).toBe('');
|
|
55
|
+
});
|
|
56
|
+
test('ignored plugin notices stay visible', () => {
|
|
57
|
+
const part = {
|
|
58
|
+
id: 'prt_notice',
|
|
59
|
+
type: 'text',
|
|
60
|
+
sessionID: 'ses_test',
|
|
61
|
+
messageID: 'msg_notice',
|
|
62
|
+
text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
|
|
63
|
+
ignored: true,
|
|
64
|
+
};
|
|
65
|
+
expect(formatPart(part)).toBe('Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('asSystemLine', () => {
|
|
69
|
+
test('prefixes every line', () => {
|
|
70
|
+
expect(asSystemLine('hello\nworld')).toMatchInlineSnapshot(`
|
|
71
|
+
"-# hello
|
|
72
|
+
-# world"
|
|
73
|
+
`);
|
|
74
|
+
});
|
|
75
|
+
test('does not double-prefix', () => {
|
|
76
|
+
expect(asSystemLine('-# already marked')).toMatchInlineSnapshot(`"-# already marked"`);
|
|
77
|
+
});
|
|
45
78
|
});
|
|
46
79
|
describe('asDiscordQuote', () => {
|
|
47
80
|
test('prefixes every line', () => {
|
|
@@ -61,6 +94,14 @@ describe('asDiscordQuote', () => {
|
|
|
61
94
|
`);
|
|
62
95
|
});
|
|
63
96
|
});
|
|
97
|
+
describe('quotedTextFitsOneDiscordMessage', () => {
|
|
98
|
+
test('accepts a short line', () => {
|
|
99
|
+
expect(quotedTextFitsOneDiscordMessage('done')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
test('rejects text whose quote would split across Discord messages', () => {
|
|
102
|
+
expect(quotedTextFitsOneDiscordMessage('x'.repeat(1999))).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
64
105
|
describe('planAssistantTurnFlush', () => {
|
|
65
106
|
function text(id, body, ended = true) {
|
|
66
107
|
return { id, type: 'text', text: body, time: ended ? { end: 1 } : undefined };
|
|
@@ -144,7 +185,7 @@ describe('planAssistantTurnFlush', () => {
|
|
|
144
185
|
}
|
|
145
186
|
`);
|
|
146
187
|
});
|
|
147
|
-
test('progress
|
|
188
|
+
test('progress sends tools immediately while unfinished text is held', () => {
|
|
148
189
|
expect(plan([text('t1', 'status', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
|
|
149
190
|
{
|
|
150
191
|
"hold": [
|
|
@@ -152,15 +193,51 @@ describe('planAssistantTurnFlush', () => {
|
|
|
152
193
|
"id": "t1",
|
|
153
194
|
"quoteText": false,
|
|
154
195
|
},
|
|
196
|
+
],
|
|
197
|
+
"send": [
|
|
155
198
|
{
|
|
156
199
|
"id": "tool1",
|
|
157
200
|
"quoteText": false,
|
|
158
201
|
},
|
|
159
202
|
],
|
|
203
|
+
}
|
|
204
|
+
`);
|
|
205
|
+
});
|
|
206
|
+
test('progress holds unfinished text only when nothing else is ready', () => {
|
|
207
|
+
expect(plan([text('t1', 'status', false)], 'progress')).toMatchInlineSnapshot(`
|
|
208
|
+
{
|
|
209
|
+
"hold": [
|
|
210
|
+
{
|
|
211
|
+
"id": "t1",
|
|
212
|
+
"quoteText": false,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
160
215
|
"send": [],
|
|
161
216
|
}
|
|
162
217
|
`);
|
|
163
218
|
});
|
|
219
|
+
test('progress sends completed text and later tools while later text is still open', () => {
|
|
220
|
+
expect(plan([text('t1', 'first'), text('t2', 'second', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
|
|
221
|
+
{
|
|
222
|
+
"hold": [
|
|
223
|
+
{
|
|
224
|
+
"id": "t2",
|
|
225
|
+
"quoteText": false,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
"send": [
|
|
229
|
+
{
|
|
230
|
+
"id": "t1",
|
|
231
|
+
"quoteText": true,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
"id": "tool1",
|
|
235
|
+
"quoteText": false,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
}
|
|
239
|
+
`);
|
|
240
|
+
});
|
|
164
241
|
test('progress holds unfinished last text until it ends', () => {
|
|
165
242
|
expect(plan([text('t1', 'first'), text('t2', 'second', false)], 'progress')).toMatchInlineSnapshot(`
|
|
166
243
|
{
|
|
@@ -284,6 +361,17 @@ describe('planAssistantTurnFlush', () => {
|
|
|
284
361
|
test('progress still quotes two-line earlier text', () => {
|
|
285
362
|
expect(plan([text('t1', 'line one\nline two'), tool('tool1'), text('t2', 'answer')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: true });
|
|
286
363
|
});
|
|
364
|
+
test('progress keeps sleep-tool quote context after that tool is already sent', () => {
|
|
365
|
+
const parts = [text('t1', 'wait'), tool('sleep1', 'kimaki_sleep')];
|
|
366
|
+
expect(plan(parts, 'progress').send.find((entry) => entry.id === 't1')).toEqual({
|
|
367
|
+
id: 't1',
|
|
368
|
+
quoteText: false,
|
|
369
|
+
});
|
|
370
|
+
expect(plan([text('t1', 'wait')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({
|
|
371
|
+
id: 't1',
|
|
372
|
+
quoteText: true,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
287
375
|
test('progress does not quote earlier text before an interactive tool', () => {
|
|
288
376
|
expect(plan([text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
|
|
289
377
|
expect(plan([text('t1', 'wait'), tool('sleep1', 'kimaki_sleep'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
|
|
@@ -318,15 +406,35 @@ describe('sessionPartContent', () => {
|
|
|
318
406
|
});
|
|
319
407
|
});
|
|
320
408
|
describe('collectSessionChunks', () => {
|
|
321
|
-
function textPart({ id, text, messageID, }) {
|
|
409
|
+
function textPart({ id, text, messageID, synthetic, }) {
|
|
322
410
|
return {
|
|
323
411
|
id,
|
|
324
412
|
type: 'text',
|
|
325
413
|
sessionID: 'ses_test',
|
|
326
414
|
messageID,
|
|
327
415
|
text,
|
|
416
|
+
synthetic,
|
|
328
417
|
};
|
|
329
418
|
}
|
|
419
|
+
test('skips synthetic text parts', () => {
|
|
420
|
+
const { chunks } = collectSessionChunks({
|
|
421
|
+
messages: [
|
|
422
|
+
{
|
|
423
|
+
info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
|
|
424
|
+
parts: [
|
|
425
|
+
textPart({
|
|
426
|
+
id: 'branch',
|
|
427
|
+
text: '\n[current git branch is main]\n',
|
|
428
|
+
messageID: 'msg_1',
|
|
429
|
+
synthetic: true,
|
|
430
|
+
}),
|
|
431
|
+
textPart({ id: 't1', text: 'working', messageID: 'msg_1' }),
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
});
|
|
436
|
+
expect(chunks.map((chunk) => chunk.content)).toEqual(['> working']);
|
|
437
|
+
});
|
|
330
438
|
test('quotes short text throughout a turn', () => {
|
|
331
439
|
const { chunks } = collectSessionChunks({
|
|
332
440
|
messages: [
|
package/dist/opencode.js
CHANGED
|
@@ -305,16 +305,21 @@ function ensureProcessCleanupHandlersRegistered() {
|
|
|
305
305
|
killSingleServerProcessNow({ reason: 'process-exit' });
|
|
306
306
|
killStartingServerProcessNow({ reason: 'process-exit' });
|
|
307
307
|
});
|
|
308
|
-
// Fallback for
|
|
309
|
-
//
|
|
310
|
-
process
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
308
|
+
// Fallback for CLI subcommands without their own signal handling. Any signal
|
|
309
|
+
// listener disables Node's default exit, so if we are the only listener we
|
|
310
|
+
// must exit ourselves. Otherwise the process ignores Ctrl+C and, in the bot,
|
|
311
|
+
// keeps holding the hrana lock port. If another owner exists (the bot
|
|
312
|
+
// lifecycle handlers, a subcommand), it decides when to exit.
|
|
313
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
314
|
+
process.on(signal, () => {
|
|
315
|
+
killSingleServerProcessNow({ reason: signal.toLowerCase() });
|
|
316
|
+
killStartingServerProcessNow({ reason: signal.toLowerCase() });
|
|
317
|
+
if (process.listenerCount(signal) > 1) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
process.exit(128 + os.constants.signals[signal]);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
318
323
|
}
|
|
319
324
|
// ── Resolve opencode binary ──────────────────────────────────────
|
|
320
325
|
// Resolve the full path to the opencode binary so we can spawn without
|
|
@@ -806,6 +811,15 @@ async function startSingleServer({ directory, } = {}) {
|
|
|
806
811
|
return waitResult;
|
|
807
812
|
}
|
|
808
813
|
serverReady = true;
|
|
814
|
+
// stopOpencodeServer() may have run while we waited (bot shutdown). Never
|
|
815
|
+
// publish a server that nobody will stop.
|
|
816
|
+
if (global.shuttingDown || serverProcess.killed) {
|
|
817
|
+
killStartingServerProcessNow({ reason: 'stopped-during-startup' });
|
|
818
|
+
if (startingServerProcess === serverProcess) {
|
|
819
|
+
startingServerProcess = null;
|
|
820
|
+
}
|
|
821
|
+
return new ServerStartError({ port, reason: 'stopped during startup' });
|
|
822
|
+
}
|
|
809
823
|
opencodeLogger.log(`Server ready on port ${port}`);
|
|
810
824
|
// Always dump startup logs so plugin loading errors and other startup output
|
|
811
825
|
// are visible in kimaki.log.
|
|
@@ -1143,6 +1157,9 @@ export function extractSdkErrorMessage(error) {
|
|
|
1143
1157
|
* Used for process teardown, tests, and explicit restarts.
|
|
1144
1158
|
*/
|
|
1145
1159
|
export async function stopOpencodeServer() {
|
|
1160
|
+
// A server still booting lives only in startingServerProcess.
|
|
1161
|
+
killStartingServerProcessNow({ reason: 'stop-opencode-server' });
|
|
1162
|
+
startingServerProcess = null;
|
|
1146
1163
|
if (!singleServer) {
|
|
1147
1164
|
return false;
|
|
1148
1165
|
}
|
|
@@ -1168,8 +1185,6 @@ export async function stopOpencodeServer() {
|
|
|
1168
1185
|
opencodeLogger.warn(killResult.message);
|
|
1169
1186
|
}
|
|
1170
1187
|
}
|
|
1171
|
-
killStartingServerProcessNow({ reason: 'stop-opencode-server' });
|
|
1172
|
-
startingServerProcess = null;
|
|
1173
1188
|
singleServer = null;
|
|
1174
1189
|
clientCache.clear();
|
|
1175
1190
|
serverRetryCount = 0;
|
|
@@ -78,7 +78,7 @@ describe('abort clears pending question dropdown', () => {
|
|
|
78
78
|
"--- from: user (question-abort-tester)
|
|
79
79
|
QUESTION_SELECT_QUEUE_MARKER abort-test
|
|
80
80
|
--- from: assistant (TestBot)
|
|
81
|
-
|
|
81
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
82
82
|
**Select action**
|
|
83
83
|
How to proceed?
|
|
84
84
|
[user selects dropdown: 0]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// E2e test: a question answered after its run was aborted elsewhere must not be
|
|
2
|
+
// lost. The original run is dead, so question.reply is a no-op; kimaki resumes
|
|
3
|
+
// the session by sending the answers back as a fresh prompt.
|
|
4
|
+
import { describe, test, expect } from 'vitest';
|
|
5
|
+
import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
6
|
+
import { waitForBotMessageContaining } from './test-utils.js';
|
|
7
|
+
import { pendingQuestionContexts } from './commands/ask-question.js';
|
|
8
|
+
import { getOpencodeClient } from './opencode.js';
|
|
9
|
+
import { getThreadSession } from './database.js';
|
|
10
|
+
import { getRuntime } from './session-handler/thread-session-runtime.js';
|
|
11
|
+
const TEXT_CHANNEL_ID = '200000000000001032';
|
|
12
|
+
describe('question answered after external abort resumes session', () => {
|
|
13
|
+
const ctx = setupQueueAdvancedSuite({
|
|
14
|
+
channelId: TEXT_CHANNEL_ID,
|
|
15
|
+
channelName: 'qa-question-abort-resume',
|
|
16
|
+
dirName: 'qa-question-abort-resume',
|
|
17
|
+
username: 'question-resume-tester',
|
|
18
|
+
});
|
|
19
|
+
test('answering a dropdown after opencode abort resumes with the answers', async () => {
|
|
20
|
+
const marker = 'QUESTION_ABORT_RESUME_MARKER';
|
|
21
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
22
|
+
content: marker,
|
|
23
|
+
});
|
|
24
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
25
|
+
timeout: 8_000,
|
|
26
|
+
predicate: (t) => {
|
|
27
|
+
return t.name === marker;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const th = ctx.discord.thread(thread.id);
|
|
31
|
+
const questionMessages = await waitForBotMessageContaining({
|
|
32
|
+
discord: ctx.discord,
|
|
33
|
+
threadId: thread.id,
|
|
34
|
+
text: 'How to proceed?',
|
|
35
|
+
timeout: 12_000,
|
|
36
|
+
});
|
|
37
|
+
const questionMsg = questionMessages.find((message) => {
|
|
38
|
+
return message.content.includes('How to proceed?');
|
|
39
|
+
});
|
|
40
|
+
if (!questionMsg) {
|
|
41
|
+
throw new Error('Expected question message');
|
|
42
|
+
}
|
|
43
|
+
const pendingEntry = [...pendingQuestionContexts.entries()].find(([, context]) => {
|
|
44
|
+
return context.thread.id === thread.id;
|
|
45
|
+
});
|
|
46
|
+
if (!pendingEntry) {
|
|
47
|
+
throw new Error('Expected pending question context');
|
|
48
|
+
}
|
|
49
|
+
const contextHash = pendingEntry[0];
|
|
50
|
+
// Abort directly through opencode (simulating a different opencode client),
|
|
51
|
+
// so kimaki's dropdown context stays live but the question is gone in
|
|
52
|
+
// opencode. kimaki's own abort would clear the dropdown instead.
|
|
53
|
+
const sessionId = await getThreadSession(thread.id);
|
|
54
|
+
if (!sessionId) {
|
|
55
|
+
throw new Error('Expected session id');
|
|
56
|
+
}
|
|
57
|
+
const client = getOpencodeClient(ctx.directories.projectDirectory);
|
|
58
|
+
if (!client) {
|
|
59
|
+
throw new Error('Expected opencode client');
|
|
60
|
+
}
|
|
61
|
+
await client.session.abort({
|
|
62
|
+
sessionID: sessionId,
|
|
63
|
+
directory: ctx.directories.projectDirectory,
|
|
64
|
+
});
|
|
65
|
+
// Wait until kimaki sees the session go idle (mirrors a user answering
|
|
66
|
+
// after they aborted in another opencode).
|
|
67
|
+
const runtime = getRuntime(thread.id);
|
|
68
|
+
if (!runtime) {
|
|
69
|
+
throw new Error('Expected runtime');
|
|
70
|
+
}
|
|
71
|
+
const idleStart = Date.now();
|
|
72
|
+
while (runtime.isBusy() && Date.now() - idleStart < 8_000) {
|
|
73
|
+
await new Promise((resolve) => {
|
|
74
|
+
setTimeout(resolve, 50);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
expect(runtime.isBusy()).toBe(false);
|
|
78
|
+
// Answer the dropdown. reply fails (question no longer pending), so kimaki
|
|
79
|
+
// resumes the session with the answers as a new prompt.
|
|
80
|
+
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
81
|
+
messageId: questionMsg.id,
|
|
82
|
+
customId: `ask_question:${contextHash}:0`,
|
|
83
|
+
values: ['0'],
|
|
84
|
+
});
|
|
85
|
+
await th.waitForInteractionAck({
|
|
86
|
+
interactionId: interaction.id,
|
|
87
|
+
timeout: 8_000,
|
|
88
|
+
});
|
|
89
|
+
await waitForBotMessageContaining({
|
|
90
|
+
discord: ctx.discord,
|
|
91
|
+
threadId: thread.id,
|
|
92
|
+
text: 'resumed-after-abort',
|
|
93
|
+
timeout: 12_000,
|
|
94
|
+
});
|
|
95
|
+
const timeline = await th.text({ showInteractions: true });
|
|
96
|
+
expect(timeline).toContain('How to proceed?');
|
|
97
|
+
expect(timeline).toContain('» **question-resume-tester:** Alpha');
|
|
98
|
+
expect(timeline).toContain('resumed-after-abort');
|
|
99
|
+
}, 25_000);
|
|
100
|
+
});
|
|
@@ -127,7 +127,7 @@ describe('question text before dropdown after abort retry', () => {
|
|
|
127
127
|
"--- from: user (question-abort-retry-tester)
|
|
128
128
|
ABORT_RETRY_LEFTOVER_TEXT_MARKER
|
|
129
129
|
--- from: assistant (TestBot)
|
|
130
|
-
|
|
130
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
131
131
|
> abort-retry-started
|
|
132
132
|
--- from: user (question-abort-retry-tester)
|
|
133
133
|
QUESTION_AFTER_ABORT_RETRY_MARKER
|
|
@@ -34,7 +34,7 @@ describe('question text before dropdown', () => {
|
|
|
34
34
|
"--- from: user (question-text-tester)
|
|
35
35
|
QUESTION_AFTER_TEXT_MARKER
|
|
36
36
|
--- from: assistant (TestBot)
|
|
37
|
-
|
|
37
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
38
38
|
PLAN_TEXT_BEFORE_QUESTION
|
|
39
39
|
**Next step**
|
|
40
40
|
What next?"
|
|
@@ -153,9 +153,9 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
153
153
|
"--- from: user (queue-advanced-tester)
|
|
154
154
|
Reply with exactly: abort-no-footer-setup
|
|
155
155
|
--- from: assistant (TestBot)
|
|
156
|
-
|
|
156
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
157
157
|
> ok
|
|
158
|
-
|
|
158
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
159
159
|
--- from: user (queue-advanced-tester)
|
|
160
160
|
SLOW_ABORT_MARKER run long response"
|
|
161
161
|
`);
|
|
@@ -284,7 +284,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
284
284
|
"--- from: user (queue-advanced-tester)
|
|
285
285
|
Reply with exactly: force-abort-setup
|
|
286
286
|
--- from: assistant (TestBot)
|
|
287
|
-
|
|
287
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
288
288
|
--- from: user (queue-advanced-tester)
|
|
289
289
|
SLOW_ABORT_MARKER run long response"
|
|
290
290
|
`);
|
|
@@ -122,15 +122,15 @@ describe('queue advanced: action buttons', () => {
|
|
|
122
122
|
"--- from: user (queue-action-tester)
|
|
123
123
|
Reply with exactly: action-button-setup
|
|
124
124
|
--- from: assistant (TestBot)
|
|
125
|
-
|
|
125
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
126
126
|
> ok
|
|
127
|
-
|
|
127
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
128
128
|
**Action Required**
|
|
129
129
|
_Selected: Continue action-buttons flow_
|
|
130
130
|
[user clicks button]
|
|
131
131
|
» **queue-action-tester:** Continue action-buttons flow
|
|
132
132
|
> action-buttons-click-continued
|
|
133
|
-
|
|
133
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
134
134
|
`);
|
|
135
135
|
expect(timeline).toContain('action-buttons-click-continued');
|
|
136
136
|
}, 20_000);
|
|
@@ -195,9 +195,9 @@ describe('queue advanced: action buttons', () => {
|
|
|
195
195
|
"--- from: user (queue-action-tester)
|
|
196
196
|
Reply with exactly: action-button-dismiss-setup
|
|
197
197
|
--- from: assistant (TestBot)
|
|
198
|
-
|
|
198
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
199
199
|
> ok
|
|
200
|
-
|
|
200
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
201
201
|
**Action Required**
|
|
202
202
|
_Buttons dismissed._
|
|
203
203
|
--- from: user (queue-action-tester)
|
|
@@ -820,8 +820,71 @@ export function createDeterministicMatchers() {
|
|
|
820
820
|
],
|
|
821
821
|
},
|
|
822
822
|
};
|
|
823
|
+
// Question tool for the abort-then-answer case: model asks a question via
|
|
824
|
+
// dropdown. The test aborts the run before answering (session goes idle),
|
|
825
|
+
// then answers via select. Because the original run is dead, kimaki resumes
|
|
826
|
+
// the session by sending the answers back as a new prompt.
|
|
827
|
+
const questionAbortResumeMatcher = {
|
|
828
|
+
id: 'question-abort-resume-marker',
|
|
829
|
+
priority: 107,
|
|
830
|
+
when: {
|
|
831
|
+
lastMessageRole: 'user',
|
|
832
|
+
latestUserTextIncludes: 'QUESTION_ABORT_RESUME_MARKER',
|
|
833
|
+
rawPromptRegex: '^(?!.*question-abort-resume-call)',
|
|
834
|
+
},
|
|
835
|
+
then: {
|
|
836
|
+
parts: [
|
|
837
|
+
{ type: 'stream-start', warnings: [] },
|
|
838
|
+
{
|
|
839
|
+
type: 'tool-call',
|
|
840
|
+
toolCallId: 'question-abort-resume-call',
|
|
841
|
+
toolName: 'question',
|
|
842
|
+
input: JSON.stringify({
|
|
843
|
+
questions: [{
|
|
844
|
+
question: 'How to proceed?',
|
|
845
|
+
header: 'Select action',
|
|
846
|
+
options: [
|
|
847
|
+
{ label: 'Alpha', description: 'Alpha option' },
|
|
848
|
+
{ label: 'Beta', description: 'Beta option' },
|
|
849
|
+
],
|
|
850
|
+
}],
|
|
851
|
+
}),
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
type: 'finish',
|
|
855
|
+
finishReason: 'tool-calls',
|
|
856
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
857
|
+
},
|
|
858
|
+
],
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
// Follow-up after an aborted question is answered: kimaki resumes with a
|
|
862
|
+
// prompt containing the answers summary.
|
|
863
|
+
const questionAbortResumeFollowupMatcher = {
|
|
864
|
+
id: 'question-abort-resume-followup',
|
|
865
|
+
priority: 107,
|
|
866
|
+
when: {
|
|
867
|
+
lastMessageRole: 'user',
|
|
868
|
+
latestUserTextIncludes: 'Answers to your previous questions:',
|
|
869
|
+
},
|
|
870
|
+
then: {
|
|
871
|
+
parts: [
|
|
872
|
+
{ type: 'stream-start', warnings: [] },
|
|
873
|
+
{ type: 'text-start', id: 'resume-reply' },
|
|
874
|
+
{ type: 'text-delta', id: 'resume-reply', delta: 'resumed-after-abort' },
|
|
875
|
+
{ type: 'text-end', id: 'resume-reply' },
|
|
876
|
+
{
|
|
877
|
+
type: 'finish',
|
|
878
|
+
finishReason: 'stop',
|
|
879
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
880
|
+
},
|
|
881
|
+
],
|
|
882
|
+
},
|
|
883
|
+
};
|
|
823
884
|
return [
|
|
824
885
|
questionSelectDrainMatcher,
|
|
886
|
+
questionAbortResumeMatcher,
|
|
887
|
+
questionAbortResumeFollowupMatcher,
|
|
825
888
|
slowAbortMatcher,
|
|
826
889
|
slowBusyMatcher,
|
|
827
890
|
typingRepulseMatcher,
|
|
@@ -34,9 +34,9 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
34
34
|
"--- from: user (queue-advanced-tester)
|
|
35
35
|
Reply with exactly: footer-check
|
|
36
36
|
--- from: assistant (TestBot)
|
|
37
|
-
|
|
37
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
38
38
|
> ok
|
|
39
|
-
|
|
39
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
40
40
|
`);
|
|
41
41
|
const foundFooter = footerMessages.some((m) => {
|
|
42
42
|
return m.author.id === ctx.discord.botUserId
|
|
@@ -44,16 +44,16 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
44
44
|
});
|
|
45
45
|
expect(foundFooter).toBe(true);
|
|
46
46
|
}, 8_000);
|
|
47
|
-
test('
|
|
48
|
-
store.setState({ footerMentionsEnabled:
|
|
47
|
+
test('enable-footer-mentions mentions the thread creator', async () => {
|
|
48
|
+
store.setState({ footerMentionsEnabled: true });
|
|
49
49
|
try {
|
|
50
50
|
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
51
|
-
content: 'Reply with exactly: footer-
|
|
51
|
+
content: 'Reply with exactly: footer-with-mention',
|
|
52
52
|
});
|
|
53
53
|
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
54
54
|
timeout: 4_000,
|
|
55
55
|
predicate: (t) => {
|
|
56
|
-
return t.name === 'Reply with exactly: footer-
|
|
56
|
+
return t.name === 'Reply with exactly: footer-with-mention';
|
|
57
57
|
},
|
|
58
58
|
});
|
|
59
59
|
const th = ctx.discord.thread(thread.id);
|
|
@@ -65,15 +65,15 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
65
65
|
});
|
|
66
66
|
expect(await th.text()).toMatchInlineSnapshot(`
|
|
67
67
|
"--- from: user (queue-advanced-tester)
|
|
68
|
-
Reply with exactly: footer-
|
|
68
|
+
Reply with exactly: footer-with-mention
|
|
69
69
|
--- from: assistant (TestBot)
|
|
70
|
-
|
|
70
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
71
71
|
> ok
|
|
72
|
-
|
|
72
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
|
|
73
73
|
`);
|
|
74
74
|
}
|
|
75
75
|
finally {
|
|
76
|
-
store.setState({ footerMentionsEnabled:
|
|
76
|
+
store.setState({ footerMentionsEnabled: false });
|
|
77
77
|
}
|
|
78
78
|
}, 8_000);
|
|
79
79
|
test('footer appears after second message in same session', async () => {
|
|
@@ -121,14 +121,14 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
121
121
|
"--- from: user (queue-advanced-tester)
|
|
122
122
|
Reply with exactly: footer-multi-setup
|
|
123
123
|
--- from: assistant (TestBot)
|
|
124
|
-
|
|
124
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
125
125
|
> ok
|
|
126
|
-
|
|
126
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
127
127
|
--- from: user (queue-advanced-tester)
|
|
128
128
|
Reply with exactly: footer-multi-second
|
|
129
129
|
--- from: assistant (TestBot)
|
|
130
130
|
> ok
|
|
131
|
-
|
|
131
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
132
132
|
`);
|
|
133
133
|
if (footerCount >= 2) {
|
|
134
134
|
expect(footerCount).toBeGreaterThanOrEqual(2);
|
|
@@ -216,9 +216,9 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
216
216
|
"--- from: user (queue-advanced-tester)
|
|
217
217
|
Reply with exactly: interrupt-footer-setup
|
|
218
218
|
--- from: assistant (TestBot)
|
|
219
|
-
|
|
219
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
220
220
|
> ok
|
|
221
|
-
|
|
221
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
222
222
|
--- from: user (queue-advanced-tester)
|
|
223
223
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
224
224
|
--- from: assistant (TestBot)
|
|
@@ -227,7 +227,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
227
227
|
Reply with exactly: interrupt-footer-followup
|
|
228
228
|
--- from: assistant (TestBot)
|
|
229
229
|
> ok
|
|
230
|
-
|
|
230
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
231
231
|
`);
|
|
232
232
|
expect(followupUserIdx).toBeGreaterThanOrEqual(0);
|
|
233
233
|
expect(okReplyIdx).toBeGreaterThan(followupUserIdx);
|
|
@@ -274,13 +274,13 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
274
274
|
"--- from: user (queue-advanced-tester)
|
|
275
275
|
TOOL_CALL_FOOTER_MARKER
|
|
276
276
|
--- from: assistant (TestBot)
|
|
277
|
-
|
|
277
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
278
278
|
> running tool
|
|
279
279
|
|
|
280
280
|
┣ bash _echo tool-call-footer-test_
|
|
281
281
|
|
|
282
282
|
> tool call completed
|
|
283
|
-
|
|
283
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
284
284
|
`);
|
|
285
285
|
}, 10_000);
|
|
286
286
|
});
|
|
@@ -248,9 +248,9 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
248
248
|
"--- from: user (queue-model-switch-tester)
|
|
249
249
|
Reply with exactly: model-switcher-setup
|
|
250
250
|
--- from: assistant (TestBot)
|
|
251
|
-
|
|
251
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
252
252
|
> ok
|
|
253
|
-
|
|
253
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
254
254
|
Model set for this session:
|
|
255
255
|
**Deterministic Provider** / **deterministic-v3**
|
|
256
256
|
\`deterministic-provider/deterministic-v3\`
|
|
@@ -259,13 +259,12 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
259
259
|
--- from: user (queue-model-switch-tester)
|
|
260
260
|
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
261
261
|
--- from: assistant (TestBot)
|
|
262
|
-
> ok
|
|
263
262
|
> starting sleep 100
|
|
264
263
|
--- from: user (queue-model-switch-tester)
|
|
265
264
|
Reply with exactly: model-switcher-followup
|
|
266
265
|
--- from: assistant (TestBot)
|
|
267
266
|
> ok
|
|
268
|
-
|
|
267
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
|
|
269
268
|
`);
|
|
270
269
|
expect(footer).toBeDefined();
|
|
271
270
|
expect(footer?.content).toContain('deterministic-v3');
|
|
@@ -89,7 +89,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
89
89
|
"--- from: user (queue-permission-tester)
|
|
90
90
|
PERMISSION_TYPING_MARKER
|
|
91
91
|
--- from: assistant (TestBot)
|
|
92
|
-
|
|
92
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
93
93
|
> requesting external read permission
|
|
94
94
|
|
|
95
95
|
┣ read *.zprofile*
|
|
@@ -101,7 +101,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
101
101
|
[user clicks button]
|
|
102
102
|
|
|
103
103
|
> permission-flow-done
|
|
104
|
-
|
|
104
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
105
105
|
`);
|
|
106
106
|
const timeline = await th.text({
|
|
107
107
|
showTyping: true,
|