kimaki 0.4.78 → 0.4.80
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/anthropic-auth-plugin.js +628 -0
- package/dist/channel-management.js +2 -2
- package/dist/cli.js +316 -129
- package/dist/commands/action-buttons.js +1 -1
- package/dist/commands/login.js +634 -277
- package/dist/commands/model.js +91 -6
- package/dist/commands/paginated-select.js +57 -0
- package/dist/commands/resume.js +2 -2
- package/dist/commands/tasks.js +205 -0
- package/dist/commands/undo-redo.js +80 -18
- package/dist/context-awareness-plugin.js +347 -0
- package/dist/database.js +103 -7
- package/dist/db.js +39 -1
- package/dist/discord-bot.js +42 -19
- package/dist/discord-urls.js +11 -0
- package/dist/discord-ws-proxy.js +350 -0
- package/dist/discord-ws-proxy.test.js +500 -0
- package/dist/errors.js +1 -1
- package/dist/gateway-session.js +163 -0
- package/dist/hrana-server.js +114 -4
- package/dist/interaction-handler.js +30 -7
- package/dist/ipc-tools-plugin.js +186 -0
- package/dist/message-preprocessing.js +56 -11
- package/dist/onboarding-welcome.js +1 -1
- package/dist/opencode-interrupt-plugin.js +133 -75
- package/dist/opencode-plugin.js +12 -389
- package/dist/opencode.js +59 -5
- package/dist/parse-permission-rules.test.js +117 -0
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
- package/dist/session-handler/thread-session-runtime.js +68 -29
- package/dist/startup-time.e2e.test.js +295 -0
- package/dist/store.js +1 -0
- package/dist/system-message.js +3 -1
- package/dist/task-runner.js +7 -3
- package/dist/task-schedule.js +12 -0
- package/dist/thread-message-queue.e2e.test.js +13 -1
- package/dist/undo-redo.e2e.test.js +166 -0
- package/dist/utils.js +4 -1
- package/dist/voice-attachment.js +34 -0
- package/dist/voice-handler.js +11 -9
- package/dist/voice-message.e2e.test.js +78 -0
- package/dist/voice.test.js +31 -0
- package/package.json +12 -7
- package/skills/egaki/SKILL.md +80 -15
- package/skills/errore/SKILL.md +13 -0
- package/skills/lintcn/SKILL.md +749 -0
- package/skills/npm-package/SKILL.md +17 -3
- package/skills/spiceflow/SKILL.md +14 -0
- package/skills/zele/SKILL.md +9 -0
- package/src/anthropic-auth-plugin.ts +732 -0
- package/src/channel-management.ts +2 -2
- package/src/cli.ts +354 -132
- package/src/commands/action-buttons.ts +1 -0
- package/src/commands/login.ts +836 -337
- package/src/commands/model.ts +102 -7
- package/src/commands/paginated-select.ts +81 -0
- package/src/commands/resume.ts +6 -1
- package/src/commands/tasks.ts +293 -0
- package/src/commands/undo-redo.ts +87 -20
- package/src/context-awareness-plugin.ts +469 -0
- package/src/database.ts +138 -7
- package/src/db.ts +40 -1
- package/src/discord-bot.ts +46 -19
- package/src/discord-urls.ts +12 -0
- package/src/errors.ts +1 -1
- package/src/hrana-server.ts +124 -3
- package/src/interaction-handler.ts +41 -9
- package/src/ipc-tools-plugin.ts +228 -0
- package/src/message-preprocessing.ts +82 -11
- package/src/onboarding-welcome.ts +1 -1
- package/src/opencode-interrupt-plugin.ts +164 -91
- package/src/opencode-plugin.ts +13 -483
- package/src/opencode.ts +60 -5
- package/src/parse-permission-rules.test.ts +127 -0
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +82 -20
- package/src/startup-time.e2e.test.ts +372 -0
- package/src/store.ts +8 -0
- package/src/system-message.ts +10 -1
- package/src/task-runner.ts +9 -22
- package/src/task-schedule.ts +15 -0
- package/src/thread-message-queue.e2e.test.ts +14 -1
- package/src/undo-redo.e2e.test.ts +207 -0
- package/src/utils.ts +7 -0
- package/src/voice-attachment.ts +51 -0
- package/src/voice-handler.ts +15 -7
- package/src/voice-message.e2e.test.ts +95 -0
- package/src/voice.test.ts +36 -0
- package/src/onboarding-tutorial-plugin.ts +0 -93
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// E2e test: queued messages must drain immediately when the session is idle,
|
|
2
|
+
// even if action buttons are still pending. The isSessionBusy check is
|
|
3
|
+
// sufficient — hasPendingInteractiveUi() should NOT block queue drain.
|
|
4
|
+
import { describe, test, expect } from 'vitest';
|
|
5
|
+
import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
6
|
+
import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
7
|
+
import { getThreadSession } from './database.js';
|
|
8
|
+
import { pendingActionButtonContexts, showActionButtons, } from './commands/action-buttons.js';
|
|
9
|
+
const TEXT_CHANNEL_ID = '200000000000001020';
|
|
10
|
+
describe('queue drain with pending interactive UI', () => {
|
|
11
|
+
const ctx = setupQueueAdvancedSuite({
|
|
12
|
+
channelId: TEXT_CHANNEL_ID,
|
|
13
|
+
channelName: 'qa-drain-interactive-ui',
|
|
14
|
+
dirName: 'qa-drain-interactive-ui',
|
|
15
|
+
username: 'drain-ui-tester',
|
|
16
|
+
});
|
|
17
|
+
test('queued message drains immediately while action buttons are still pending', async () => {
|
|
18
|
+
// 1. Create a thread with a first completed reply
|
|
19
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
20
|
+
content: 'Reply with exactly: drain-button-setup',
|
|
21
|
+
});
|
|
22
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
23
|
+
timeout: 4_000,
|
|
24
|
+
predicate: (t) => {
|
|
25
|
+
return t.name === 'Reply with exactly: drain-button-setup';
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const th = ctx.discord.thread(thread.id);
|
|
29
|
+
await waitForBotMessageContaining({
|
|
30
|
+
discord: ctx.discord,
|
|
31
|
+
threadId: thread.id,
|
|
32
|
+
userId: TEST_USER_ID,
|
|
33
|
+
text: 'ok',
|
|
34
|
+
timeout: 4_000,
|
|
35
|
+
});
|
|
36
|
+
await waitForFooterMessage({
|
|
37
|
+
discord: ctx.discord,
|
|
38
|
+
threadId: thread.id,
|
|
39
|
+
timeout: 4_000,
|
|
40
|
+
afterMessageIncludes: 'ok',
|
|
41
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
42
|
+
});
|
|
43
|
+
// 2. Show action buttons (session is idle, buttons are pending)
|
|
44
|
+
const currentSessionId = await getThreadSession(thread.id);
|
|
45
|
+
if (!currentSessionId) {
|
|
46
|
+
throw new Error('Expected thread session id');
|
|
47
|
+
}
|
|
48
|
+
const channel = await ctx.botClient.channels.fetch(thread.id);
|
|
49
|
+
if (!channel || !channel.isThread()) {
|
|
50
|
+
throw new Error('Expected Discord thread channel');
|
|
51
|
+
}
|
|
52
|
+
await showActionButtons({
|
|
53
|
+
thread: channel,
|
|
54
|
+
sessionId: currentSessionId,
|
|
55
|
+
directory: ctx.directories.projectDirectory,
|
|
56
|
+
buttons: [{ label: 'Pending button', color: 'white' }],
|
|
57
|
+
});
|
|
58
|
+
// Verify buttons are pending
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
while (Date.now() - start < 4_000) {
|
|
61
|
+
const entry = [...pendingActionButtonContexts.entries()].find(([, context]) => {
|
|
62
|
+
return context.thread.id === thread.id && Boolean(context.messageId);
|
|
63
|
+
});
|
|
64
|
+
if (entry) {
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
await new Promise((resolve) => {
|
|
68
|
+
setTimeout(resolve, 100);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
expect([...pendingActionButtonContexts.values()].some((c) => {
|
|
72
|
+
return c.thread.id === thread.id;
|
|
73
|
+
})).toBe(true);
|
|
74
|
+
// 3. Queue a message via /queue while buttons are still pending.
|
|
75
|
+
// The queue should drain immediately because session is idle.
|
|
76
|
+
// Currently FAILS: hasPendingInteractiveUi() blocks tryDrainQueue().
|
|
77
|
+
const { id: queueInteractionId } = await th.user(TEST_USER_ID)
|
|
78
|
+
.runSlashCommand({
|
|
79
|
+
name: 'queue',
|
|
80
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: post-button-drain' }],
|
|
81
|
+
});
|
|
82
|
+
const queueAck = await th.waitForInteractionAck({
|
|
83
|
+
interactionId: queueInteractionId,
|
|
84
|
+
timeout: 4_000,
|
|
85
|
+
});
|
|
86
|
+
if (!queueAck.messageId) {
|
|
87
|
+
throw new Error('Expected /queue response message id');
|
|
88
|
+
}
|
|
89
|
+
// 4. Queued message should dispatch immediately (not stay "Queued").
|
|
90
|
+
// The dispatch indicator should appear quickly.
|
|
91
|
+
await waitForBotMessageContaining({
|
|
92
|
+
discord: ctx.discord,
|
|
93
|
+
threadId: thread.id,
|
|
94
|
+
text: '» **drain-ui-tester:** Reply with exactly: post-button-drain',
|
|
95
|
+
timeout: 4_000,
|
|
96
|
+
});
|
|
97
|
+
// 5. Wait for the footer after the drained message completes
|
|
98
|
+
await waitForFooterMessage({
|
|
99
|
+
discord: ctx.discord,
|
|
100
|
+
threadId: thread.id,
|
|
101
|
+
timeout: 4_000,
|
|
102
|
+
afterMessageIncludes: '» **drain-ui-tester:**',
|
|
103
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
104
|
+
});
|
|
105
|
+
const timeline = await th.text({ showInteractions: true });
|
|
106
|
+
expect(timeline).toMatchInlineSnapshot(`
|
|
107
|
+
"--- from: user (drain-ui-tester)
|
|
108
|
+
Reply with exactly: drain-button-setup
|
|
109
|
+
--- from: assistant (TestBot)
|
|
110
|
+
⬥ ok
|
|
111
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
112
|
+
**Action Required**
|
|
113
|
+
[user interaction]
|
|
114
|
+
» **drain-ui-tester:** Reply with exactly: post-button-drain
|
|
115
|
+
⬥ ok
|
|
116
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
117
|
+
`);
|
|
118
|
+
}, 20_000);
|
|
119
|
+
});
|
|
@@ -10,7 +10,7 @@ import path from 'node:path';
|
|
|
10
10
|
import prettyMilliseconds from 'pretty-ms';
|
|
11
11
|
import * as errore from 'errore';
|
|
12
12
|
import * as threadState from './thread-runtime-state.js';
|
|
13
|
-
import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissions, subscribeOpencodeServerLifecycle, } from '../opencode.js';
|
|
13
|
+
import { getOpencodeClient, initializeOpencodeForDirectory, buildSessionPermissions, parsePermissionRules, subscribeOpencodeServerLifecycle, } from '../opencode.js';
|
|
14
14
|
import { isAbortError } from '../utils.js';
|
|
15
15
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
16
16
|
import { sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, } from '../discord-utils.js';
|
|
@@ -580,12 +580,16 @@ export class ThreadSessionRuntime {
|
|
|
580
580
|
}
|
|
581
581
|
const compacted = structuredClone(event);
|
|
582
582
|
if (compacted.type === 'message.updated') {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
583
|
+
// Strip heavy fields from ALL roles. Derivation only needs lightweight
|
|
584
|
+
// metadata (id, role, sessionID, parentID, time, finish, error, modelID,
|
|
585
|
+
// providerID, mode, tokens). The parts array on assistant messages grows
|
|
586
|
+
// with every tool call and was the primary OOM vector — 1000 buffer entries
|
|
587
|
+
// each carrying the full cumulative parts array reached 4GB+.
|
|
588
|
+
const info = compacted.properties.info;
|
|
589
|
+
delete info.system;
|
|
590
|
+
delete info.summary;
|
|
591
|
+
delete info.tools;
|
|
592
|
+
delete info.parts;
|
|
589
593
|
return compacted;
|
|
590
594
|
}
|
|
591
595
|
const part = compacted.properties.part;
|
|
@@ -787,8 +791,16 @@ export class ThreadSessionRuntime {
|
|
|
787
791
|
// Global events (tui.toast.show) bypass the guard.
|
|
788
792
|
// Subtask sessions also bypass — they're tracked in subtaskSessions.
|
|
789
793
|
async handleEvent(event) {
|
|
790
|
-
//
|
|
791
|
-
|
|
794
|
+
// Skip message.part.delta from the event buffer — no derivation function
|
|
795
|
+
// (isSessionBusy, doesLatestUserTurnHaveNaturalCompletion, waitForEvent,
|
|
796
|
+
// etc.) uses them. During long streaming responses they flood the 1000-slot
|
|
797
|
+
// buffer, evicting session.status busy events that isSessionBusy needs,
|
|
798
|
+
// causing tryDrainQueue to drain the local queue while the session is
|
|
799
|
+
// actually still busy. This was the root cause of "? queue" messages
|
|
800
|
+
// interrupting instead of queuing.
|
|
801
|
+
if (event.type !== 'message.part.delta') {
|
|
802
|
+
this.appendEventToBuffer(event);
|
|
803
|
+
}
|
|
792
804
|
const sessionId = this.state?.sessionId;
|
|
793
805
|
const eventSessionId = getOpencodeEventSessionId(event);
|
|
794
806
|
if (shouldLogSessionEvents) {
|
|
@@ -1377,7 +1389,7 @@ export class ThreadSessionRuntime {
|
|
|
1377
1389
|
});
|
|
1378
1390
|
if (showResult instanceof Error) {
|
|
1379
1391
|
logger.error('[ACTION] Failed to show action buttons:', showResult);
|
|
1380
|
-
await sendThreadMessage(this.thread, `Failed to show action buttons: ${showResult.message}
|
|
1392
|
+
await sendThreadMessage(this.thread, `Failed to show action buttons: ${showResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
1381
1393
|
}
|
|
1382
1394
|
},
|
|
1383
1395
|
});
|
|
@@ -1579,7 +1591,7 @@ export class ThreadSessionRuntime {
|
|
|
1579
1591
|
}
|
|
1580
1592
|
const errorMessage = formatSessionErrorFromProps(properties.error);
|
|
1581
1593
|
logger.error(`Sending error to thread: ${errorMessage}`);
|
|
1582
|
-
await sendThreadMessage(this.thread, `✗ opencode session error: ${errorMessage}
|
|
1594
|
+
await sendThreadMessage(this.thread, `✗ opencode session error: ${errorMessage}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
1583
1595
|
await this.persistEventBufferDebounced.flush();
|
|
1584
1596
|
// Inject synthetic idle so isSessionBusy() returns false and queued
|
|
1585
1597
|
// messages can drain. Without this, a session error leaves the event
|
|
@@ -1803,13 +1815,16 @@ export class ThreadSessionRuntime {
|
|
|
1803
1815
|
// Helper: stop typing and drain queued local messages on error.
|
|
1804
1816
|
const cleanupOnError = async (errorMessage) => {
|
|
1805
1817
|
this.stopTyping();
|
|
1806
|
-
await sendThreadMessage(this.thread, errorMessage
|
|
1818
|
+
await sendThreadMessage(this.thread, errorMessage, {
|
|
1819
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
1820
|
+
});
|
|
1807
1821
|
await this.tryDrainQueue({ showIndicator: true });
|
|
1808
1822
|
};
|
|
1809
1823
|
// ── Ensure session ──────────────────────────────────────
|
|
1810
1824
|
const sessionResult = await this.ensureSession({
|
|
1811
1825
|
prompt: input.prompt,
|
|
1812
1826
|
agent: input.agent,
|
|
1827
|
+
permissions: input.permissions,
|
|
1813
1828
|
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
1814
1829
|
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
1815
1830
|
});
|
|
@@ -2039,6 +2054,7 @@ export class ThreadSessionRuntime {
|
|
|
2039
2054
|
command: input.command,
|
|
2040
2055
|
agent: input.agent,
|
|
2041
2056
|
model: input.model,
|
|
2057
|
+
permissions: input.permissions,
|
|
2042
2058
|
sessionStartScheduleKind: input.sessionStartSource?.scheduleKind,
|
|
2043
2059
|
sessionStartScheduledTaskId: input.sessionStartSource?.scheduledTaskId,
|
|
2044
2060
|
};
|
|
@@ -2051,7 +2067,6 @@ export class ThreadSessionRuntime {
|
|
|
2051
2067
|
const position = stateAfterEnqueue?.queueItems.length ?? 0;
|
|
2052
2068
|
const willDrainNow = stateAfterEnqueue
|
|
2053
2069
|
? (stateAfterEnqueue.queueItems.length > 0
|
|
2054
|
-
&& !this.hasPendingInteractiveUi()
|
|
2055
2070
|
&& !this.isMainSessionBusy())
|
|
2056
2071
|
: false;
|
|
2057
2072
|
result = !willDrainNow && position > 0
|
|
@@ -2127,6 +2142,13 @@ export class ThreadSessionRuntime {
|
|
|
2127
2142
|
mode: result.mode,
|
|
2128
2143
|
preprocess: undefined,
|
|
2129
2144
|
};
|
|
2145
|
+
const hasPromptText = resolvedInput.prompt.trim().length > 0;
|
|
2146
|
+
const hasImages = (resolvedInput.images?.length || 0) > 0;
|
|
2147
|
+
if (!hasPromptText && !hasImages && !resolvedInput.command) {
|
|
2148
|
+
logger.warn(`[INGRESS] Skipping empty preprocessed input threadId=${this.threadId}`);
|
|
2149
|
+
resolveOuter({ queued: false });
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2130
2152
|
// Route with the resolved mode through normal paths.
|
|
2131
2153
|
// Await the enqueue so session state (ensureSession, setThreadSession)
|
|
2132
2154
|
// is persisted before the next message's preprocessing reads it.
|
|
@@ -2228,9 +2250,11 @@ export class ThreadSessionRuntime {
|
|
|
2228
2250
|
if (thread.queueItems.length === 0) {
|
|
2229
2251
|
return;
|
|
2230
2252
|
}
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2253
|
+
// Interactive UI (action buttons, questions, permissions) does NOT block
|
|
2254
|
+
// queue drain. The isSessionBusy check is sufficient: questions and
|
|
2255
|
+
// permissions keep the OpenCode session busy, so drain is naturally
|
|
2256
|
+
// blocked. Action buttons are fire-and-forget (session already idle),
|
|
2257
|
+
// so queued messages should dispatch immediately.
|
|
2234
2258
|
const sessionBusy = thread.sessionId
|
|
2235
2259
|
? isSessionBusy({ events: this.eventBuffer, sessionId: thread.sessionId })
|
|
2236
2260
|
: false;
|
|
@@ -2284,12 +2308,13 @@ export class ThreadSessionRuntime {
|
|
|
2284
2308
|
const sessionResult = await this.ensureSession({
|
|
2285
2309
|
prompt: input.prompt,
|
|
2286
2310
|
agent: input.agent,
|
|
2311
|
+
permissions: input.permissions,
|
|
2287
2312
|
sessionStartScheduleKind: input.sessionStartScheduleKind,
|
|
2288
2313
|
sessionStartScheduledTaskId: input.sessionStartScheduledTaskId,
|
|
2289
2314
|
});
|
|
2290
2315
|
if (sessionResult instanceof Error) {
|
|
2291
2316
|
this.stopTyping();
|
|
2292
|
-
await sendThreadMessage(this.thread, `✗ ${sessionResult.message}
|
|
2317
|
+
await sendThreadMessage(this.thread, `✗ ${sessionResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
2293
2318
|
// Show indicator: this dispatch failed, so the next queued message
|
|
2294
2319
|
// has been waiting — the user needs to see which one is starting.
|
|
2295
2320
|
await this.tryDrainQueue({ showIndicator: true });
|
|
@@ -2327,7 +2352,7 @@ export class ThreadSessionRuntime {
|
|
|
2327
2352
|
});
|
|
2328
2353
|
if (earlyAgentResult instanceof Error) {
|
|
2329
2354
|
this.stopTyping();
|
|
2330
|
-
await sendThreadMessage(this.thread, `Failed to resolve agent: ${earlyAgentResult.message}
|
|
2355
|
+
await sendThreadMessage(this.thread, `Failed to resolve agent: ${earlyAgentResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
2331
2356
|
// Show indicator: dispatch failed mid-setup, next queued message was waiting.
|
|
2332
2357
|
await this.tryDrainQueue({ showIndicator: true });
|
|
2333
2358
|
return;
|
|
@@ -2363,7 +2388,7 @@ export class ThreadSessionRuntime {
|
|
|
2363
2388
|
]);
|
|
2364
2389
|
if (earlyModelResult instanceof Error) {
|
|
2365
2390
|
this.stopTyping();
|
|
2366
|
-
await sendThreadMessage(this.thread, `Failed to resolve model: ${earlyModelResult.message}
|
|
2391
|
+
await sendThreadMessage(this.thread, `Failed to resolve model: ${earlyModelResult.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
2367
2392
|
// Show indicator: dispatch failed mid-setup, next queued message was waiting.
|
|
2368
2393
|
await this.tryDrainQueue({ showIndicator: true });
|
|
2369
2394
|
return;
|
|
@@ -2496,7 +2521,7 @@ export class ThreadSessionRuntime {
|
|
|
2496
2521
|
if (timedOut) {
|
|
2497
2522
|
logger.warn(`[DISPATCH] Command timed out after 30s sessionId=${session.id}`);
|
|
2498
2523
|
this.stopTyping();
|
|
2499
|
-
await sendThreadMessage(this.thread, '✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.');
|
|
2524
|
+
await sendThreadMessage(this.thread, '✗ Command timed out after 30 seconds. Try a shorter command or run it with /run-shell-command.', { flags: NOTIFY_MESSAGE_FLAGS });
|
|
2500
2525
|
await this.dispatchAction(() => {
|
|
2501
2526
|
return this.tryDrainQueue({ showIndicator: true });
|
|
2502
2527
|
});
|
|
@@ -2511,7 +2536,7 @@ export class ThreadSessionRuntime {
|
|
|
2511
2536
|
logger.error(`[DISPATCH] Command SDK call failed: ${commandResponse.message}`);
|
|
2512
2537
|
void notifyError(commandResponse, 'Failed to send command to OpenCode');
|
|
2513
2538
|
this.stopTyping();
|
|
2514
|
-
await sendThreadMessage(this.thread, `✗ Unexpected bot Error: ${commandResponse.message}
|
|
2539
|
+
await sendThreadMessage(this.thread, `✗ Unexpected bot Error: ${commandResponse.message}`, { flags: NOTIFY_MESSAGE_FLAGS });
|
|
2515
2540
|
await this.dispatchAction(() => {
|
|
2516
2541
|
return this.tryDrainQueue({ showIndicator: true });
|
|
2517
2542
|
});
|
|
@@ -2528,7 +2553,9 @@ export class ThreadSessionRuntime {
|
|
|
2528
2553
|
logger.error(`[DISPATCH] ${apiError.message}`);
|
|
2529
2554
|
void notifyError(apiError, 'OpenCode API error during command');
|
|
2530
2555
|
this.stopTyping();
|
|
2531
|
-
await sendThreadMessage(this.thread, `✗ ${apiError.message}
|
|
2556
|
+
await sendThreadMessage(this.thread, `✗ ${apiError.message}`, {
|
|
2557
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
2558
|
+
});
|
|
2532
2559
|
await this.dispatchAction(() => {
|
|
2533
2560
|
return this.tryDrainQueue({ showIndicator: true });
|
|
2534
2561
|
});
|
|
@@ -2572,7 +2599,9 @@ export class ThreadSessionRuntime {
|
|
|
2572
2599
|
logger.error(`[DISPATCH] Prompt API call failed: ${errorMessage}`);
|
|
2573
2600
|
void notifyError(errorObject, 'OpenCode API error during local queue prompt');
|
|
2574
2601
|
this.stopTyping();
|
|
2575
|
-
await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}
|
|
2602
|
+
await sendThreadMessage(this.thread, `✗ OpenCode API error: ${errorMessage}`, {
|
|
2603
|
+
flags: NOTIFY_MESSAGE_FLAGS,
|
|
2604
|
+
});
|
|
2576
2605
|
await this.dispatchAction(() => {
|
|
2577
2606
|
return this.tryDrainQueue({ showIndicator: true });
|
|
2578
2607
|
});
|
|
@@ -2582,7 +2611,7 @@ export class ThreadSessionRuntime {
|
|
|
2582
2611
|
}
|
|
2583
2612
|
// ── Session Ensure ──────────────────────────────────────────
|
|
2584
2613
|
// Creates or reuses the OpenCode session for this thread.
|
|
2585
|
-
async ensureSession({ prompt, agent, sessionStartScheduleKind, sessionStartScheduledTaskId, }) {
|
|
2614
|
+
async ensureSession({ prompt, agent, permissions, sessionStartScheduleKind, sessionStartScheduledTaskId, }) {
|
|
2586
2615
|
const directory = this.projectDirectory;
|
|
2587
2616
|
// Resolve worktree info for server initialization
|
|
2588
2617
|
const worktreeInfo = await getThreadWorktree(this.thread.id);
|
|
@@ -2625,10 +2654,15 @@ export class ThreadSessionRuntime {
|
|
|
2625
2654
|
// access its own project directory (and worktree origin if applicable)
|
|
2626
2655
|
// without prompts. These override the server-level 'ask' default via
|
|
2627
2656
|
// opencode's findLast() rule evaluation.
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2657
|
+
// CLI --permission rules are appended after base rules so they win
|
|
2658
|
+
// via opencode's findLast() evaluation.
|
|
2659
|
+
const sessionPermissions = [
|
|
2660
|
+
...buildSessionPermissions({
|
|
2661
|
+
directory: this.sdkDirectory,
|
|
2662
|
+
originalRepoDirectory,
|
|
2663
|
+
}),
|
|
2664
|
+
...parsePermissionRules(permissions ?? []),
|
|
2665
|
+
];
|
|
2632
2666
|
const sessionResponse = await getClient().session.create({
|
|
2633
2667
|
title: sessionTitle,
|
|
2634
2668
|
directory: this.sdkDirectory,
|
|
@@ -2759,7 +2793,12 @@ export class ThreadSessionRuntime {
|
|
|
2759
2793
|
: `${folderName} ⋅ `;
|
|
2760
2794
|
const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*`;
|
|
2761
2795
|
this.stopTyping();
|
|
2762
|
-
|
|
2796
|
+
// Skip notification if there's a queued message next — the user only
|
|
2797
|
+
// needs to be notified when the entire queue finishes.
|
|
2798
|
+
const queuedNext = (threadState.getThreadState(this.threadId)?.queueItems.length ?? 0) > 0;
|
|
2799
|
+
await sendThreadMessage(this.thread, footerText, {
|
|
2800
|
+
flags: queuedNext ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
|
|
2801
|
+
});
|
|
2763
2802
|
logger.log(`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`);
|
|
2764
2803
|
}
|
|
2765
2804
|
/** Reset per-run state for the next prompt dispatch. */
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Measures time-to-ready for the kimaki Discord bot startup.
|
|
2
|
+
// Used as a baseline to track startup performance and guide optimizations
|
|
3
|
+
// for scale-to-zero deployments where cold start time is critical.
|
|
4
|
+
//
|
|
5
|
+
// Measures each phase independently:
|
|
6
|
+
// 1. Hrana server start (DB + lock port)
|
|
7
|
+
// 2. Database init (Prisma connect via HTTP)
|
|
8
|
+
// 3. Discord.js client creation + login (Gateway READY)
|
|
9
|
+
// 4. startDiscordBot (event handlers + markDiscordGatewayReady)
|
|
10
|
+
// 5. OpenCode server startup (spawn + health poll)
|
|
11
|
+
// 6. Total wall-clock time from zero to "bot ready"
|
|
12
|
+
//
|
|
13
|
+
// Uses discord-digital-twin so Gateway READY is instant (no real Discord).
|
|
14
|
+
// OpenCode startup uses deterministic provider (no real LLM).
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import url from 'node:url';
|
|
18
|
+
import { describe, test, expect, afterAll } from 'vitest';
|
|
19
|
+
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
20
|
+
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
21
|
+
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
22
|
+
import { setDataDir } from './config.js';
|
|
23
|
+
import { startDiscordBot } from './discord-bot.js';
|
|
24
|
+
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, } from './database.js';
|
|
25
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
26
|
+
import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
|
|
27
|
+
import { chooseLockPort, cleanupTestSessions } from './test-utils.js';
|
|
28
|
+
function createRunDirectories() {
|
|
29
|
+
const root = path.resolve(process.cwd(), 'tmp', 'startup-time-e2e');
|
|
30
|
+
fs.mkdirSync(root, { recursive: true });
|
|
31
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
32
|
+
const projectDirectory = path.join(root, 'project');
|
|
33
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
34
|
+
return { root, dataDir, projectDirectory };
|
|
35
|
+
}
|
|
36
|
+
function createDiscordJsClient({ restUrl }) {
|
|
37
|
+
return new Client({
|
|
38
|
+
intents: [
|
|
39
|
+
GatewayIntentBits.Guilds,
|
|
40
|
+
GatewayIntentBits.GuildMessages,
|
|
41
|
+
GatewayIntentBits.MessageContent,
|
|
42
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
43
|
+
],
|
|
44
|
+
partials: [
|
|
45
|
+
Partials.Channel,
|
|
46
|
+
Partials.Message,
|
|
47
|
+
Partials.User,
|
|
48
|
+
Partials.ThreadMember,
|
|
49
|
+
],
|
|
50
|
+
rest: {
|
|
51
|
+
api: restUrl,
|
|
52
|
+
version: '10',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function createMinimalMatchers() {
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
id: 'startup-test-reply',
|
|
60
|
+
priority: 10,
|
|
61
|
+
when: {
|
|
62
|
+
lastMessageRole: 'user',
|
|
63
|
+
rawPromptIncludes: 'startup-test',
|
|
64
|
+
},
|
|
65
|
+
then: {
|
|
66
|
+
parts: [
|
|
67
|
+
{ type: 'stream-start', warnings: [] },
|
|
68
|
+
{ type: 'text-start', id: 'startup-reply' },
|
|
69
|
+
{ type: 'text-delta', id: 'startup-reply', delta: 'ok' },
|
|
70
|
+
{ type: 'text-end', id: 'startup-reply' },
|
|
71
|
+
{
|
|
72
|
+
type: 'finish',
|
|
73
|
+
finishReason: 'stop',
|
|
74
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
const TEST_USER_ID = '900000000000000777';
|
|
82
|
+
const TEXT_CHANNEL_ID = '900000000000000778';
|
|
83
|
+
describe('startup time measurement', () => {
|
|
84
|
+
let directories;
|
|
85
|
+
let discord;
|
|
86
|
+
let botClient = null;
|
|
87
|
+
const testStartTime = Date.now();
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
if (directories) {
|
|
90
|
+
await cleanupTestSessions({
|
|
91
|
+
projectDirectory: directories.projectDirectory,
|
|
92
|
+
testStartTime,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (botClient) {
|
|
96
|
+
botClient.destroy();
|
|
97
|
+
}
|
|
98
|
+
await Promise.all([
|
|
99
|
+
stopOpencodeServer().catch(() => { }),
|
|
100
|
+
closeDatabase().catch(() => { }),
|
|
101
|
+
stopHranaServer().catch(() => { }),
|
|
102
|
+
discord?.stop().catch(() => { }),
|
|
103
|
+
]);
|
|
104
|
+
delete process.env['KIMAKI_LOCK_PORT'];
|
|
105
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
106
|
+
if (directories) {
|
|
107
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
test('measures per-phase startup timings', async () => {
|
|
111
|
+
directories = createRunDirectories();
|
|
112
|
+
const lockPort = chooseLockPort({ key: 'startup-time-e2e' });
|
|
113
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
|
|
114
|
+
setDataDir(directories.dataDir);
|
|
115
|
+
const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
|
|
116
|
+
discord = new DigitalDiscord({
|
|
117
|
+
guild: {
|
|
118
|
+
name: 'Startup Time Guild',
|
|
119
|
+
ownerId: TEST_USER_ID,
|
|
120
|
+
},
|
|
121
|
+
channels: [
|
|
122
|
+
{
|
|
123
|
+
id: TEXT_CHANNEL_ID,
|
|
124
|
+
name: 'startup-time',
|
|
125
|
+
type: ChannelType.GuildText,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
users: [
|
|
129
|
+
{
|
|
130
|
+
id: TEST_USER_ID,
|
|
131
|
+
username: 'startup-tester',
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
dbUrl: `file:${digitalDiscordDbPath}`,
|
|
135
|
+
});
|
|
136
|
+
await discord.start();
|
|
137
|
+
// Write deterministic opencode config
|
|
138
|
+
const providerNpm = url
|
|
139
|
+
.pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
|
|
140
|
+
.toString();
|
|
141
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
142
|
+
providerName: 'deterministic-provider',
|
|
143
|
+
providerNpm,
|
|
144
|
+
model: 'deterministic-v2',
|
|
145
|
+
smallModel: 'deterministic-v2',
|
|
146
|
+
settings: {
|
|
147
|
+
strict: false,
|
|
148
|
+
matchers: createMinimalMatchers(),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
152
|
+
// ── Phase timings ──
|
|
153
|
+
const totalStart = performance.now();
|
|
154
|
+
// Phase 1: Hrana server
|
|
155
|
+
const hranaStart = performance.now();
|
|
156
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
|
|
157
|
+
const hranaResult = await startHranaServer({ dbPath });
|
|
158
|
+
if (hranaResult instanceof Error) {
|
|
159
|
+
throw hranaResult;
|
|
160
|
+
}
|
|
161
|
+
process.env['KIMAKI_DB_URL'] = hranaResult;
|
|
162
|
+
const hranaMs = performance.now() - hranaStart;
|
|
163
|
+
// Phase 2: Database init
|
|
164
|
+
const dbStart = performance.now();
|
|
165
|
+
await initDatabase();
|
|
166
|
+
await setBotToken(discord.botUserId, discord.botToken);
|
|
167
|
+
await setChannelDirectory({
|
|
168
|
+
channelId: TEXT_CHANNEL_ID,
|
|
169
|
+
directory: directories.projectDirectory,
|
|
170
|
+
channelType: 'text',
|
|
171
|
+
});
|
|
172
|
+
const dbMs = performance.now() - dbStart;
|
|
173
|
+
// Phase 3+4: Discord.js login + startDiscordBot
|
|
174
|
+
// In the real cli.ts flow, login happens first (line 2077), then
|
|
175
|
+
// startDiscordBot is called with the already-logged-in client (line 2130).
|
|
176
|
+
// startDiscordBot calls login() again internally (line 1069) which is
|
|
177
|
+
// a no-op on already-connected clients. We measure them together since
|
|
178
|
+
// that's the real critical path.
|
|
179
|
+
const loginStart = performance.now();
|
|
180
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl });
|
|
181
|
+
// Don't pre-login — let startDiscordBot handle login internally.
|
|
182
|
+
// This avoids the double-login overhead that inflates measurements.
|
|
183
|
+
const loginMs = Math.round(performance.now() - loginStart);
|
|
184
|
+
const botStart = performance.now();
|
|
185
|
+
await startDiscordBot({
|
|
186
|
+
token: discord.botToken,
|
|
187
|
+
appId: discord.botUserId,
|
|
188
|
+
discordClient: botClient,
|
|
189
|
+
});
|
|
190
|
+
const botMs = performance.now() - botStart;
|
|
191
|
+
// Phase 5: OpenCode server startup (biggest bottleneck)
|
|
192
|
+
const opencodeStart = performance.now();
|
|
193
|
+
const opencodeResult = await initializeOpencodeForDirectory(directories.projectDirectory);
|
|
194
|
+
if (opencodeResult instanceof Error) {
|
|
195
|
+
throw opencodeResult;
|
|
196
|
+
}
|
|
197
|
+
const opencodeMs = performance.now() - opencodeStart;
|
|
198
|
+
const totalMs = performance.now() - totalStart;
|
|
199
|
+
const timings = {
|
|
200
|
+
hranaServerMs: Math.round(hranaMs),
|
|
201
|
+
databaseInitMs: Math.round(dbMs),
|
|
202
|
+
discordLoginMs: Math.round(loginMs),
|
|
203
|
+
startDiscordBotMs: Math.round(botMs),
|
|
204
|
+
opencodeServerMs: Math.round(opencodeMs),
|
|
205
|
+
totalMs: Math.round(totalMs),
|
|
206
|
+
};
|
|
207
|
+
// Print timings for CI/local visibility
|
|
208
|
+
console.log('\n┌─────────────────────────────────────────────┐');
|
|
209
|
+
console.log('│ Kimaki Startup Time Breakdown │');
|
|
210
|
+
console.log('├─────────────────────────────────────────────┤');
|
|
211
|
+
console.log(`│ Hrana server: ${String(timings.hranaServerMs).padStart(6)} ms │`);
|
|
212
|
+
console.log(`│ Database init: ${String(timings.databaseInitMs).padStart(6)} ms │`);
|
|
213
|
+
console.log(`│ Discord.js login: ${String(timings.discordLoginMs).padStart(6)} ms │`);
|
|
214
|
+
console.log(`│ startDiscordBot: ${String(timings.startDiscordBotMs).padStart(6)} ms │`);
|
|
215
|
+
console.log(`│ OpenCode server: ${String(timings.opencodeServerMs).padStart(6)} ms │`);
|
|
216
|
+
console.log('├─────────────────────────────────────────────┤');
|
|
217
|
+
console.log(`│ TOTAL: ${String(timings.totalMs).padStart(6)} ms │`);
|
|
218
|
+
console.log('└─────────────────────────────────────────────┘\n');
|
|
219
|
+
// Sanity assertions — these are baselines, not targets yet.
|
|
220
|
+
// Each phase should complete (no infinite hang).
|
|
221
|
+
expect(timings.hranaServerMs).toBeLessThan(5_000);
|
|
222
|
+
expect(timings.databaseInitMs).toBeLessThan(5_000);
|
|
223
|
+
expect(timings.discordLoginMs).toBeLessThan(10_000);
|
|
224
|
+
expect(timings.startDiscordBotMs).toBeLessThan(5_000);
|
|
225
|
+
expect(timings.opencodeServerMs).toBeLessThan(30_000);
|
|
226
|
+
expect(timings.totalMs).toBeLessThan(60_000);
|
|
227
|
+
// Verify the bot is actually functional by sending a message
|
|
228
|
+
// and getting a response (validates the full pipeline works)
|
|
229
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
230
|
+
content: 'startup-test ping',
|
|
231
|
+
});
|
|
232
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
233
|
+
timeout: 10_000,
|
|
234
|
+
});
|
|
235
|
+
const reply = await discord.thread(thread.id).waitForBotReply({
|
|
236
|
+
timeout: 30_000,
|
|
237
|
+
});
|
|
238
|
+
expect(reply.content.length).toBeGreaterThan(0);
|
|
239
|
+
expect(thread.id.length).toBeGreaterThan(0);
|
|
240
|
+
}, 120_000);
|
|
241
|
+
test('measures parallel startup (discord + opencode simultaneously)', async () => {
|
|
242
|
+
// This test reuses the infrastructure from test 1 (hrana, db already up)
|
|
243
|
+
// to measure what happens when we run Discord login + OpenCode in parallel.
|
|
244
|
+
// In a fresh cold start, hrana+db init would add ~50ms on top.
|
|
245
|
+
// Stop opencode server from test 1 so we get a fresh measurement
|
|
246
|
+
await stopOpencodeServer().catch(() => { });
|
|
247
|
+
// Destroy and recreate bot client for a clean login measurement
|
|
248
|
+
if (botClient) {
|
|
249
|
+
botClient.destroy();
|
|
250
|
+
botClient = null;
|
|
251
|
+
}
|
|
252
|
+
// ── Parallel phase: Discord login + OpenCode server simultaneously ──
|
|
253
|
+
const parallelStart = performance.now();
|
|
254
|
+
const [discordResult, opencodeResult] = await Promise.all([
|
|
255
|
+
// Discord path: create client, login, start bot
|
|
256
|
+
(async () => {
|
|
257
|
+
const loginStart = performance.now();
|
|
258
|
+
const client = createDiscordJsClient({ restUrl: discord.restUrl });
|
|
259
|
+
await startDiscordBot({
|
|
260
|
+
token: discord.botToken,
|
|
261
|
+
appId: discord.botUserId,
|
|
262
|
+
discordClient: client,
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
client,
|
|
266
|
+
totalMs: Math.round(performance.now() - loginStart),
|
|
267
|
+
};
|
|
268
|
+
})(),
|
|
269
|
+
// OpenCode path: spawn server + wait for health
|
|
270
|
+
(async () => {
|
|
271
|
+
const start = performance.now();
|
|
272
|
+
const result = await initializeOpencodeForDirectory(directories.projectDirectory);
|
|
273
|
+
if (result instanceof Error) {
|
|
274
|
+
throw result;
|
|
275
|
+
}
|
|
276
|
+
return { ms: Math.round(performance.now() - start) };
|
|
277
|
+
})(),
|
|
278
|
+
]);
|
|
279
|
+
const parallelMs = Math.round(performance.now() - parallelStart);
|
|
280
|
+
botClient = discordResult.client;
|
|
281
|
+
console.log('\n┌─────────────────────────────────────────────┐');
|
|
282
|
+
console.log('│ Parallel Startup Time Breakdown │');
|
|
283
|
+
console.log('├─────────────────────────────────────────────┤');
|
|
284
|
+
console.log(`│ Discord login+bot: ${String(discordResult.totalMs).padStart(6)} ms │`);
|
|
285
|
+
console.log(`│ OpenCode server: ${String(opencodeResult.ms).padStart(6)} ms │`);
|
|
286
|
+
console.log('├─────────────────────────────────────────────┤');
|
|
287
|
+
console.log(`│ PARALLEL TOTAL: ${String(parallelMs).padStart(6)} ms │`);
|
|
288
|
+
console.log(`│ (vs sequential: ${String(discordResult.totalMs + opencodeResult.ms).padStart(6)} ms) │`);
|
|
289
|
+
console.log('└─────────────────────────────────────────────┘\n');
|
|
290
|
+
// Parallel total should be dominated by the slower path,
|
|
291
|
+
// not the sum of both.
|
|
292
|
+
const maxSingle = Math.max(discordResult.totalMs, opencodeResult.ms);
|
|
293
|
+
expect(parallelMs).toBeLessThan(maxSingle + 500);
|
|
294
|
+
}, 120_000);
|
|
295
|
+
});
|
package/dist/store.js
CHANGED
|
@@ -10,6 +10,7 @@ export const store = createStore(() => ({
|
|
|
10
10
|
critiqueEnabled: true,
|
|
11
11
|
verboseOpencodeServer: false,
|
|
12
12
|
discordBaseUrl: 'https://discord.com',
|
|
13
|
+
gatewayToken: null,
|
|
13
14
|
registeredUserCommands: [],
|
|
14
15
|
threads: new Map(),
|
|
15
16
|
test: { deterministicTranscription: null },
|