kimaki 0.30.0 → 0.31.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 +27 -19
- package/dist/analytics.js +3 -1
- package/dist/btw-prefix-detection.js +9 -0
- package/dist/btw-prefix-detection.test.js +38 -1
- package/dist/cli-commands/send.js +5 -11
- package/dist/cli-runner.js +22 -10
- package/dist/cli-runner.test.js +34 -1
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/commands/abort.js +16 -4
- package/dist/commands/btw.js +147 -39
- package/dist/commands/context-usage.js +47 -0
- package/dist/commands/context-usage.test.js +49 -0
- package/dist/commands/fork.js +32 -1
- package/dist/commands/model.js +69 -14
- package/dist/commands/new-worktree.js +5 -2
- package/dist/commands/queue.js +37 -30
- package/dist/commands/session.js +7 -2
- package/dist/db.test.js +17 -7
- package/dist/discord-bot.js +70 -47
- package/dist/discord-utils.js +15 -1
- package/dist/errors.js +10 -5
- package/dist/external-directory-permissions.e2e.test.js +4 -4
- package/dist/external-directory-project-deny.e2e.test.js +2 -2
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/message-formatting.js +50 -15
- package/dist/message-formatting.test.js +12 -11
- package/dist/message-preprocessing.js +53 -75
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +62 -6
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-footer.e2e.test.js +8 -8
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- 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 +146 -5
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +24 -14
- package/dist/queue-question-select-drain.e2e.test.js +27 -27
- package/dist/runtime-lifecycle.e2e.test.js +6 -6
- package/dist/session-handler/agent-utils.js +16 -17
- package/dist/session-handler/event-stream-state.js +24 -16
- package/dist/session-handler/event-stream-state.test.js +281 -14
- package/dist/session-handler/thread-session-runtime.js +209 -128
- package/dist/sleep.e2e.test.js +6 -6
- package/dist/system-message.js +214 -392
- package/dist/system-message.test.js +204 -380
- package/dist/task-runner.js +3 -3
- package/dist/task-schedule.js +2 -2
- package/dist/test-utils.js +32 -6
- package/dist/thread-message-queue.e2e.test.js +132 -60
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-handler.js +2 -1
- package/dist/voice-message.e2e.test.js +22 -31
- package/dist/voice-routing-input.e2e.test.js +2 -2
- package/dist/voice-routing-question.e2e.test.js +59 -2
- package/dist/voice.js +306 -206
- package/dist/voice.test.js +161 -13
- package/dist/worktree-lifecycle.e2e.test.js +7 -7
- package/package.json +6 -9
- package/skills/critique/SKILL.md +4 -0
- package/skills/holocron/SKILL.md +0 -28
- package/skills/opensrc/SKILL.md +20 -13
- package/skills/playwriter/SKILL.md +1 -1
- package/src/agent-model.e2e.test.ts +28 -19
- package/src/analytics.ts +3 -1
- package/src/btw-prefix-detection.test.ts +39 -1
- package/src/btw-prefix-detection.ts +11 -0
- package/src/cli-commands/send.ts +11 -11
- package/src/cli-runner.test.ts +37 -0
- package/src/cli-runner.ts +26 -10
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/commands/abort.ts +20 -4
- package/src/commands/btw.ts +151 -41
- package/src/commands/context-usage.test.ts +57 -0
- package/src/commands/context-usage.ts +59 -0
- package/src/commands/fork.ts +34 -1
- package/src/commands/model.ts +81 -14
- package/src/commands/new-worktree.ts +5 -2
- package/src/commands/queue.ts +46 -30
- package/src/commands/session.ts +10 -2
- package/src/db.test.ts +18 -6
- package/src/discord-bot.ts +72 -56
- package/src/discord-utils.ts +17 -1
- package/src/errors.ts +12 -6
- package/src/external-directory-permissions.e2e.test.ts +4 -4
- package/src/external-directory-project-deny.e2e.test.ts +2 -2
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/message-formatting.test.ts +12 -11
- package/src/message-formatting.ts +52 -16
- package/src/message-preprocessing.ts +69 -91
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +73 -6
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-footer.e2e.test.ts +8 -8
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- 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 +164 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +24 -13
- package/src/queue-question-select-drain.e2e.test.ts +31 -28
- package/src/runtime-lifecycle.e2e.test.ts +6 -6
- package/src/session-handler/agent-utils.ts +25 -20
- package/src/session-handler/event-stream-state.test.ts +284 -13
- package/src/session-handler/event-stream-state.ts +37 -21
- package/src/session-handler/thread-runtime-state.ts +7 -0
- package/src/session-handler/thread-session-runtime.ts +255 -155
- package/src/sleep.e2e.test.ts +6 -6
- package/src/system-message.test.ts +216 -379
- package/src/system-message.ts +253 -389
- package/src/task-runner.ts +3 -3
- package/src/task-schedule.ts +2 -2
- package/src/test-utils.ts +55 -12
- package/src/thread-message-queue.e2e.test.ts +136 -63
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-handler.ts +2 -1
- package/src/voice-message.e2e.test.ts +22 -33
- package/src/voice-routing-input.e2e.test.ts +2 -2
- package/src/voice-routing-question.e2e.test.ts +59 -2
- package/src/voice.test.ts +164 -14
- package/src/voice.ts +400 -279
- package/src/worktree-lifecycle.e2e.test.ts +7 -7
|
@@ -13,17 +13,20 @@ export const TOOL_PREFIX = '┣ ';
|
|
|
13
13
|
export const FILE_EDIT_PREFIX = '◼︎ ';
|
|
14
14
|
export const THINKING_PREFIX = '┣ ';
|
|
15
15
|
// Discord subtext. Not `#-`.
|
|
16
|
-
export const
|
|
17
|
-
|
|
16
|
+
export const SUBTEXT_PREFIX = '-# ';
|
|
17
|
+
// Same width as ┣. Only used for tool-related status lines; other bot status lines have no glyph.
|
|
18
|
+
export const STATUS_PREFIX = '⬦ ';
|
|
18
19
|
export const QUEUE_PREFIX = '» ';
|
|
19
20
|
export const WORKTREE_PREFIX = '⬦ ';
|
|
20
|
-
export function
|
|
21
|
+
export function asSubtext(text) {
|
|
21
22
|
const lead = text.startsWith('\n') ? '\n' : '';
|
|
22
23
|
const body = lead ? text.slice(1) : text;
|
|
23
24
|
return lead + body.split('\n').map((line) => {
|
|
24
|
-
if (line.
|
|
25
|
+
if (!line.trim())
|
|
25
26
|
return line;
|
|
26
|
-
|
|
27
|
+
if (line.startsWith('-#'))
|
|
28
|
+
return line;
|
|
29
|
+
return `${SUBTEXT_PREFIX}${line}`;
|
|
27
30
|
}).join('\n');
|
|
28
31
|
}
|
|
29
32
|
/**
|
|
@@ -112,6 +115,14 @@ export function serializeMessageSnapshots(snapshots) {
|
|
|
112
115
|
* model can see all user-visible content.
|
|
113
116
|
*/
|
|
114
117
|
export function resolveMentions(message) {
|
|
118
|
+
const content = resolveContentMentions(message);
|
|
119
|
+
const extras = serializeMessageExtras(message);
|
|
120
|
+
if (!extras)
|
|
121
|
+
return content;
|
|
122
|
+
return content ? `${content}\n\n${extras}` : extras;
|
|
123
|
+
}
|
|
124
|
+
/** Message content with mentions resolved, without embeds, polls or forwards. */
|
|
125
|
+
export function resolveContentMentions(message) {
|
|
115
126
|
let content = message.content || '';
|
|
116
127
|
// Replace user mentions <@userId> or <@!userId> with @displayName
|
|
117
128
|
for (const [userId, user] of message.mentions.users) {
|
|
@@ -128,17 +139,34 @@ export function resolveMentions(message) {
|
|
|
128
139
|
const name = 'name' in channel ? channel.name : channelId;
|
|
129
140
|
content = content.replace(new RegExp(`<#${channelId}>`, 'g'), `#${name}`);
|
|
130
141
|
}
|
|
131
|
-
|
|
132
|
-
|
|
142
|
+
return content;
|
|
143
|
+
}
|
|
144
|
+
// Matches explicit queue markers at the end of a message (case-insensitive).
|
|
145
|
+
// Supported forms:
|
|
146
|
+
// - punctuation + queue: ". queue", "! queue", ". queue.", "!queue."
|
|
147
|
+
// - queue as its own final line: "text\nqueue" or just "queue"
|
|
148
|
+
// When present the suffix is stripped and the message is routed through
|
|
149
|
+
// kimaki's local queue (same as /queue command).
|
|
150
|
+
const QUEUE_SUFFIX_RE = /(?:[.!?,;:]|^)\s*queue\.?\s*$|\n\s*queue\.?\s*$/i;
|
|
151
|
+
/**
|
|
152
|
+
* Only for code that builds or reads raw prompt text. Discord message ingress
|
|
153
|
+
* must go through `resolveMessagePrompt()` in message-preprocessing.ts.
|
|
154
|
+
*/
|
|
155
|
+
export function extractQueueSuffix(prompt) {
|
|
156
|
+
if (!QUEUE_SUFFIX_RE.test(prompt)) {
|
|
157
|
+
return { prompt, forceQueue: false };
|
|
158
|
+
}
|
|
159
|
+
return { prompt: prompt.replace(QUEUE_SUFFIX_RE, '').trimEnd(), forceQueue: true };
|
|
160
|
+
}
|
|
161
|
+
/** Non-text message content (embeds, poll, forwards) serialized for the model. */
|
|
162
|
+
export function serializeMessageExtras(message) {
|
|
163
|
+
return [
|
|
133
164
|
serializeEmbeds(message.embeds),
|
|
134
165
|
serializePoll(message.poll),
|
|
135
166
|
serializeMessageSnapshots(message.messageSnapshots),
|
|
136
|
-
]
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
content = content ? `${content}\n\n${joined}` : joined;
|
|
140
|
-
}
|
|
141
|
-
return content;
|
|
167
|
+
]
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.join('\n\n');
|
|
142
170
|
}
|
|
143
171
|
/**
|
|
144
172
|
* Escapes Discord inline markdown characters so dynamic content
|
|
@@ -201,7 +229,7 @@ export function shouldQuoteIntermediateTextPart({ part, isLastInTurn, nextToolNa
|
|
|
201
229
|
const text = part.text ?? '';
|
|
202
230
|
if (text.includes('<callout'))
|
|
203
231
|
return false;
|
|
204
|
-
if (text.trim().
|
|
232
|
+
if (text.trim().includes('\n'))
|
|
205
233
|
return false;
|
|
206
234
|
if (!quotedTextFitsOneDiscordMessage(text))
|
|
207
235
|
return false;
|
|
@@ -636,9 +664,16 @@ export function formatTaskToolTitle(part) {
|
|
|
636
664
|
return '';
|
|
637
665
|
const subagentType = part.state.input?.subagent_type;
|
|
638
666
|
const agent = typeof subagentType === 'string' ? subagentType : 'task';
|
|
639
|
-
return `${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}
|
|
667
|
+
return asSubtext(`${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`);
|
|
640
668
|
}
|
|
669
|
+
// Non-text parts (tools, thinking, files) render as Discord subtext so they look dimmer than text.
|
|
641
670
|
export function formatPart(part, prefix) {
|
|
671
|
+
const formatted = formatPartBody(part, prefix);
|
|
672
|
+
if (!formatted || part.type === 'text')
|
|
673
|
+
return formatted;
|
|
674
|
+
return asSubtext(formatted);
|
|
675
|
+
}
|
|
676
|
+
function formatPartBody(part, prefix) {
|
|
642
677
|
const pfx = prefix ? `${prefix} ⋅ ` : '';
|
|
643
678
|
if (part.type === 'text') {
|
|
644
679
|
if (part.synthetic === true)
|
|
@@ -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,
|
|
4
|
+
import { asDiscordQuote, asSubtext, 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', () => {
|
|
@@ -65,15 +65,16 @@ describe('formatPart', () => {
|
|
|
65
65
|
expect(formatPart(part)).toBe('Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.');
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
-
describe('
|
|
69
|
-
test('prefixes every line', () => {
|
|
70
|
-
expect(
|
|
68
|
+
describe('asSubtext', () => {
|
|
69
|
+
test('prefixes every non-empty line', () => {
|
|
70
|
+
expect(asSubtext('hello\n\nworld')).toMatchInlineSnapshot(`
|
|
71
71
|
"-# hello
|
|
72
|
+
|
|
72
73
|
-# world"
|
|
73
74
|
`);
|
|
74
75
|
});
|
|
75
76
|
test('does not double-prefix', () => {
|
|
76
|
-
expect(
|
|
77
|
+
expect(asSubtext('-# already marked')).toMatchInlineSnapshot(`"-# already marked"`);
|
|
77
78
|
});
|
|
78
79
|
});
|
|
79
80
|
describe('asDiscordQuote', () => {
|
|
@@ -358,8 +359,8 @@ describe('planAssistantTurnFlush', () => {
|
|
|
358
359
|
}
|
|
359
360
|
`);
|
|
360
361
|
});
|
|
361
|
-
test('progress
|
|
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:
|
|
362
|
+
test('progress leaves two-line text unquoted', () => {
|
|
363
|
+
expect(plan([text('t1', 'line one\nline two'), tool('tool1'), text('t2', 'answer')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
|
|
363
364
|
});
|
|
364
365
|
test('progress keeps sleep-tool quote context after that tool is already sent', () => {
|
|
365
366
|
const parts = [text('t1', 'wait'), tool('sleep1', 'kimaki_sleep')];
|
|
@@ -477,7 +478,7 @@ describe('collectSessionChunks', () => {
|
|
|
477
478
|
});
|
|
478
479
|
expect(chunks.map((chunk) => chunk.content)).toEqual([
|
|
479
480
|
'> I will read it',
|
|
480
|
-
'┣ read',
|
|
481
|
+
'-# ┣ read',
|
|
481
482
|
]);
|
|
482
483
|
});
|
|
483
484
|
test('does not quote earlier text longer than two lines', () => {
|
|
@@ -630,7 +631,7 @@ describe('formatTaskToolTitle', () => {
|
|
|
630
631
|
input: { subagent_type: 'general' },
|
|
631
632
|
title: 'Classify pending changes',
|
|
632
633
|
sessionId: 'ses_child',
|
|
633
|
-
}))).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
|
|
634
|
+
}))).toMatchInlineSnapshot(`"-# ┣ general **Classify pending changes**"`);
|
|
634
635
|
});
|
|
635
636
|
test('prefers input description on running parts', () => {
|
|
636
637
|
expect(formatTaskToolTitle(taskPart({
|
|
@@ -638,7 +639,7 @@ describe('formatTaskToolTitle', () => {
|
|
|
638
639
|
input: { description: 'inspect repo', subagent_type: 'explore' },
|
|
639
640
|
title: 'ignored title',
|
|
640
641
|
sessionId: 'ses_child',
|
|
641
|
-
}))).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`);
|
|
642
|
+
}))).toMatchInlineSnapshot(`"-# ┣ explore **inspect repo**"`);
|
|
642
643
|
});
|
|
643
644
|
test('does not format completed parts so Discord does not post the line at the end', () => {
|
|
644
645
|
expect(formatTaskToolTitle(taskPart({
|
|
@@ -655,7 +656,7 @@ describe('formatTaskToolTitle', () => {
|
|
|
655
656
|
description: 'audit customer pages',
|
|
656
657
|
subagent_type: 'general',
|
|
657
658
|
},
|
|
658
|
-
}))).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`);
|
|
659
|
+
}))).toMatchInlineSnapshot(`"-# ┣ general **audit customer pages**"`);
|
|
659
660
|
});
|
|
660
661
|
});
|
|
661
662
|
describe('formatBashToolTitle', () => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// preserving arrival order without a separate threadIngressQueue.
|
|
10
10
|
import { DiscordOperationError, OpenCodeSdkError } from './errors.js';
|
|
11
11
|
import { getOrCreateRuntime } from './session-handler/thread-session-runtime.js';
|
|
12
|
-
import { resolveMentions, getFileAttachments, getTextAttachments, } from './message-formatting.js';
|
|
12
|
+
import { resolveMentions, resolveContentMentions, serializeMessageExtras, getFileAttachments, getTextAttachments, } from './message-formatting.js';
|
|
13
13
|
import { processVoiceAttachment } from './voice-handler.js';
|
|
14
14
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
15
15
|
import { initializeOpencodeForDirectory } from './opencode.js';
|
|
@@ -17,6 +17,7 @@ import { getCompactSessionContext, getLastSessionId } from './markdown.js';
|
|
|
17
17
|
import { getThreadSession, getThreadWorktreeOrWorkspace } from './database.js';
|
|
18
18
|
import { resolveWorkingDirectory, resolveTextChannel, sendThreadMessage } from './discord-utils.js';
|
|
19
19
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
20
|
+
import { extractBtwQueueSuffix } from './btw-prefix-detection.js';
|
|
20
21
|
import { createNewSessionThread } from './commands/session.js';
|
|
21
22
|
import * as errore from 'errore';
|
|
22
23
|
import { createLogger, LogPrefix } from './logger.js';
|
|
@@ -122,19 +123,35 @@ async function fetchAvailableAgents(getClient, directory) {
|
|
|
122
123
|
return { name: a.name, description: a.description };
|
|
123
124
|
});
|
|
124
125
|
}
|
|
125
|
-
// Matches explicit queue markers at the end of a message (case-insensitive).
|
|
126
|
-
// Supported forms:
|
|
127
|
-
// - punctuation + queue: ". queue", "! queue", ". queue.", "!queue."
|
|
128
|
-
// - queue as its own final line: "text\nqueue" or just "queue"
|
|
129
|
-
// When present the suffix is stripped and the message is routed through
|
|
130
|
-
// kimaki's local queue (same as /queue command).
|
|
131
|
-
const QUEUE_SUFFIX_RE = /(?:[.!?,;:]|^)\s*queue\.?\s*$|\n\s*queue\.?\s*$/i;
|
|
132
126
|
const REPLIED_MESSAGE_TEXT_LIMIT = 1_000;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Shared ingress step for every Discord message that becomes a prompt: user
|
|
129
|
+
* messages, `kimaki send --thread` injections and `kimaki send --channel`
|
|
130
|
+
* starter messages. Strips the queue suffix, appends embeds and text
|
|
131
|
+
* attachments, and picks the ingress mode.
|
|
132
|
+
*
|
|
133
|
+
* `text` must not contain embeds or attachments yet. The suffix is read from
|
|
134
|
+
* it first; anything appended before would push "? queue" away from the end.
|
|
135
|
+
*/
|
|
136
|
+
export async function resolveMessagePrompt({ message, text, forceQueue = false, includeExtras = true, }) {
|
|
137
|
+
const qs = extractBtwQueueSuffix(text);
|
|
138
|
+
const [images, textAttachments] = await Promise.all([
|
|
139
|
+
getFileAttachments(message),
|
|
140
|
+
getTextAttachments(message),
|
|
141
|
+
]);
|
|
142
|
+
const prompt = [
|
|
143
|
+
qs.forceBtw && !qs.forceQueue && !forceQueue ? text : qs.prompt,
|
|
144
|
+
includeExtras ? serializeMessageExtras(message) : '',
|
|
145
|
+
textAttachments,
|
|
146
|
+
]
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join('\n\n');
|
|
149
|
+
return {
|
|
150
|
+
prompt,
|
|
151
|
+
images: images.length > 0 ? images : undefined,
|
|
152
|
+
mode: qs.forceQueue || forceQueue ? 'local-queue' : 'opencode',
|
|
153
|
+
queuedAction: (qs.forceQueue || forceQueue) && qs.forceBtw ? 'btw' : undefined,
|
|
154
|
+
};
|
|
138
155
|
}
|
|
139
156
|
function shouldSkipEmptyPrompt({ message, prompt, images, hasVoiceAttachment, }) {
|
|
140
157
|
if (prompt.trim()) {
|
|
@@ -198,7 +215,7 @@ export async function preprocessExistingThreadMessage({ message, thread, project
|
|
|
198
215
|
voiceLogger.log(`[SESSION] Found session ${sessionId} for thread ${thread.id}`);
|
|
199
216
|
let messageContent = isCliInjected
|
|
200
217
|
? (message.content || '')
|
|
201
|
-
:
|
|
218
|
+
: resolveContentMentions(message);
|
|
202
219
|
const repliedMessage = await getRepliedMessageContext({ message });
|
|
203
220
|
// Fetch session context and available agents for voice transcription enrichment
|
|
204
221
|
let currentSessionContext;
|
|
@@ -271,30 +288,17 @@ export async function preprocessExistingThreadMessage({ message, thread, project
|
|
|
271
288
|
if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
|
|
272
289
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
273
290
|
}
|
|
274
|
-
|
|
275
|
-
// attachments. Otherwise a text file attachment pushes "? queue" away from
|
|
276
|
-
// the end of the string and the regex fails to match.
|
|
277
|
-
const qs = extractQueueSuffix(messageContent);
|
|
278
|
-
const fileAttachments = await getFileAttachments(message);
|
|
279
|
-
const textAttachmentsContent = await getTextAttachments(message);
|
|
280
|
-
const prompt = textAttachmentsContent
|
|
281
|
-
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
282
|
-
: qs.prompt;
|
|
283
|
-
if (shouldSkipEmptyPrompt({
|
|
291
|
+
const resolved = await resolveMessagePrompt({
|
|
284
292
|
message,
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
293
|
+
text: messageContent,
|
|
294
|
+
forceQueue: voiceResult?.queueMessage,
|
|
295
|
+
// The kimaki marker embed of CLI injections is metadata, not prompt text.
|
|
296
|
+
includeExtras: !isCliInjected,
|
|
297
|
+
});
|
|
298
|
+
if (shouldSkipEmptyPrompt({ message, ...resolved, hasVoiceAttachment })) {
|
|
289
299
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
290
300
|
}
|
|
291
|
-
return {
|
|
292
|
-
prompt,
|
|
293
|
-
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
294
|
-
repliedMessage,
|
|
295
|
-
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
296
|
-
agent: voiceResult?.agent,
|
|
297
|
-
};
|
|
301
|
+
return { ...resolved, repliedMessage, agent: voiceResult?.agent };
|
|
298
302
|
}
|
|
299
303
|
/**
|
|
300
304
|
* Pre-process a message that starts a new session in a thread (no existing
|
|
@@ -315,7 +319,7 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
315
319
|
voiceLogger.error(`Could not fetch agents for voice transcription:`, e);
|
|
316
320
|
}
|
|
317
321
|
}
|
|
318
|
-
let prompt =
|
|
322
|
+
let prompt = resolveContentMentions(message);
|
|
319
323
|
const repliedMessage = await getRepliedMessageContext({ message });
|
|
320
324
|
const voiceResult = await processVoiceAttachment({
|
|
321
325
|
message,
|
|
@@ -355,27 +359,15 @@ export async function preprocessNewSessionMessage({ message, thread, projectDire
|
|
|
355
359
|
prompt = `Context from thread:\n${starterText}\n\nUser request:\n${prompt}`;
|
|
356
360
|
}
|
|
357
361
|
}
|
|
358
|
-
const
|
|
359
|
-
const fileAttachments = await getFileAttachments(message);
|
|
360
|
-
const textAttachmentsContent = await getTextAttachments(message);
|
|
361
|
-
const finalPrompt = textAttachmentsContent
|
|
362
|
-
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
363
|
-
: qs.prompt;
|
|
364
|
-
if (shouldSkipEmptyPrompt({
|
|
362
|
+
const resolved = await resolveMessagePrompt({
|
|
365
363
|
message,
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
})) {
|
|
364
|
+
text: prompt,
|
|
365
|
+
forceQueue: voiceResult?.queueMessage,
|
|
366
|
+
});
|
|
367
|
+
if (shouldSkipEmptyPrompt({ message, ...resolved, hasVoiceAttachment })) {
|
|
370
368
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
371
369
|
}
|
|
372
|
-
return {
|
|
373
|
-
prompt: finalPrompt,
|
|
374
|
-
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
375
|
-
repliedMessage,
|
|
376
|
-
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
377
|
-
agent: voiceResult?.agent,
|
|
378
|
-
};
|
|
370
|
+
return { ...resolved, repliedMessage, agent: voiceResult?.agent };
|
|
379
371
|
}
|
|
380
372
|
/**
|
|
381
373
|
* Pre-process a message from a text channel (creates a new thread).
|
|
@@ -394,7 +386,7 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
|
|
|
394
386
|
voiceLogger.error(`Could not fetch agents for voice transcription:`, e);
|
|
395
387
|
}
|
|
396
388
|
}
|
|
397
|
-
let messageContent =
|
|
389
|
+
let messageContent = resolveContentMentions(message);
|
|
398
390
|
const repliedMessage = await getRepliedMessageContext({ message });
|
|
399
391
|
const voiceResult = await processVoiceAttachment({
|
|
400
392
|
message,
|
|
@@ -415,27 +407,13 @@ export async function preprocessNewThreadMessage({ message, thread, projectDirec
|
|
|
415
407
|
if (hasVoiceAttachment && !voiceResult && !messageContent.trim()) {
|
|
416
408
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
417
409
|
}
|
|
418
|
-
|
|
419
|
-
// attachments (same fix as preprocessExistingThreadMessage).
|
|
420
|
-
const qs = extractQueueSuffix(messageContent);
|
|
421
|
-
const fileAttachments = await getFileAttachments(message);
|
|
422
|
-
const textAttachmentsContent = await getTextAttachments(message);
|
|
423
|
-
const prompt = textAttachmentsContent
|
|
424
|
-
? `${qs.prompt}\n\n${textAttachmentsContent}`
|
|
425
|
-
: qs.prompt;
|
|
426
|
-
if (shouldSkipEmptyPrompt({
|
|
410
|
+
const resolved = await resolveMessagePrompt({
|
|
427
411
|
message,
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
})) {
|
|
412
|
+
text: messageContent,
|
|
413
|
+
forceQueue: voiceResult?.queueMessage,
|
|
414
|
+
});
|
|
415
|
+
if (shouldSkipEmptyPrompt({ message, ...resolved, hasVoiceAttachment })) {
|
|
432
416
|
return { prompt: '', mode: 'opencode', skip: true };
|
|
433
417
|
}
|
|
434
|
-
return {
|
|
435
|
-
prompt,
|
|
436
|
-
images: fileAttachments.length > 0 ? fileAttachments : undefined,
|
|
437
|
-
repliedMessage,
|
|
438
|
-
mode: qs.forceQueue || voiceResult?.queueMessage ? 'local-queue' : 'opencode',
|
|
439
|
-
agent: voiceResult?.agent,
|
|
440
|
-
};
|
|
418
|
+
return { ...resolved, repliedMessage, agent: voiceResult?.agent };
|
|
441
419
|
}
|
|
@@ -50,7 +50,7 @@ describe('abort clears pending question dropdown', () => {
|
|
|
50
50
|
if (!runtime) {
|
|
51
51
|
throw new Error('Expected runtime for question abort test');
|
|
52
52
|
}
|
|
53
|
-
runtime.abortActiveRun('test-question-abort');
|
|
53
|
+
void runtime.abortActiveRun('test-question-abort');
|
|
54
54
|
// Click the now-dead dropdown. The answer echo must never appear.
|
|
55
55
|
const interaction = await th.user(TEST_USER_ID).selectMenu({
|
|
56
56
|
messageId: questionMsg.id,
|
|
@@ -113,7 +113,7 @@ describe('question text before dropdown after abort retry', () => {
|
|
|
113
113
|
if (!runtime) {
|
|
114
114
|
throw new Error('Expected runtime for abort-retry question test');
|
|
115
115
|
}
|
|
116
|
-
runtime.abortActiveRun('test-question-abort-retry-flush');
|
|
116
|
+
void runtime.abortActiveRun('test-question-abort-retry-flush');
|
|
117
117
|
await th.user(TEST_USER_ID).sendMessage({
|
|
118
118
|
content: 'QUESTION_AFTER_ABORT_RETRY_MARKER',
|
|
119
119
|
});
|
|
@@ -4,7 +4,7 @@ import { describe, test, expect } from 'vitest';
|
|
|
4
4
|
import { setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
5
5
|
import { getRuntime, } from './session-handler/thread-session-runtime.js';
|
|
6
6
|
import { getThreadState } from './session-handler/thread-runtime-state.js';
|
|
7
|
-
import { setSessionModel } from './database.js';
|
|
7
|
+
import { listThreadQueueItems, setSessionModel } from './database.js';
|
|
8
8
|
import { isFooterMessage, waitForFooterMessage, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
|
|
9
9
|
const TEXT_CHANNEL_ID = '200000000000001003';
|
|
10
10
|
const e2eTest = describe;
|
|
@@ -57,7 +57,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
57
57
|
if (!runtime) {
|
|
58
58
|
throw new Error('Expected runtime to exist for explicit-abort test');
|
|
59
59
|
}
|
|
60
|
-
runtime.abortActiveRun('test-explicit-abort');
|
|
60
|
+
void runtime.abortActiveRun('test-explicit-abort');
|
|
61
61
|
await th.user(TEST_USER_ID).sendMessage({
|
|
62
62
|
content: 'Reply with exactly: papa',
|
|
63
63
|
});
|
|
@@ -108,6 +108,62 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
108
108
|
});
|
|
109
109
|
expect(userPapaIndex).toBeLessThan(lastBotIndex);
|
|
110
110
|
}, 12_000);
|
|
111
|
+
test('/abort clears queued messages so they are never sent', async () => {
|
|
112
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
113
|
+
content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
|
|
114
|
+
});
|
|
115
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
116
|
+
timeout: 4_000,
|
|
117
|
+
predicate: (t) => {
|
|
118
|
+
return t.name === 'PLUGIN_TIMEOUT_SLEEP_MARKER';
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const th = ctx.discord.thread(thread.id);
|
|
122
|
+
await waitForBotMessageContaining({
|
|
123
|
+
discord: ctx.discord,
|
|
124
|
+
threadId: thread.id,
|
|
125
|
+
userId: TEST_USER_ID,
|
|
126
|
+
text: 'starting sleep',
|
|
127
|
+
timeout: 4_000,
|
|
128
|
+
});
|
|
129
|
+
const { id: queueInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
130
|
+
name: 'queue',
|
|
131
|
+
options: [{ name: 'message', type: 3, value: 'Reply with exactly: abort-cleared-queued' }],
|
|
132
|
+
});
|
|
133
|
+
await th.waitForInteractionAck({ interactionId: queueInteractionId, timeout: 4_000 });
|
|
134
|
+
expect(getRuntime(thread.id)?.getQueueLength()).toBe(1);
|
|
135
|
+
expect(await listThreadQueueItems(thread.id)).toHaveLength(1);
|
|
136
|
+
const { id: abortInteractionId } = await th.user(TEST_USER_ID).runSlashCommand({
|
|
137
|
+
name: 'abort',
|
|
138
|
+
});
|
|
139
|
+
await th.waitForInteractionAck({ interactionId: abortInteractionId, timeout: 4_000 });
|
|
140
|
+
await waitForBotMessageContaining({
|
|
141
|
+
discord: ctx.discord,
|
|
142
|
+
threadId: thread.id,
|
|
143
|
+
userId: TEST_USER_ID,
|
|
144
|
+
text: 'Request **aborted**',
|
|
145
|
+
timeout: 4_000,
|
|
146
|
+
});
|
|
147
|
+
// Queued message must never be dispatched after abort.
|
|
148
|
+
for (let i = 0; i < 10; i++) {
|
|
149
|
+
await new Promise((resolve) => {
|
|
150
|
+
setTimeout(resolve, 20);
|
|
151
|
+
});
|
|
152
|
+
const text = await th.text();
|
|
153
|
+
expect(text).not.toContain('» **queue-advanced-tester:** Reply with exactly: abort-cleared-queued');
|
|
154
|
+
}
|
|
155
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
156
|
+
"--- from: user (queue-advanced-tester)
|
|
157
|
+
PLUGIN_TIMEOUT_SLEEP_MARKER
|
|
158
|
+
--- from: assistant (TestBot)
|
|
159
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
160
|
+
> starting sleep 100
|
|
161
|
+
-# Queued message (position 1)
|
|
162
|
+
Request **aborted**, cleared 1 queued message"
|
|
163
|
+
`);
|
|
164
|
+
expect(getRuntime(thread.id)?.getQueueLength()).toBe(0);
|
|
165
|
+
expect(await listThreadQueueItems(thread.id)).toHaveLength(0);
|
|
166
|
+
}, 10_000);
|
|
111
167
|
test('explicit abort emits MessageAbortedError and does not emit footer', async () => {
|
|
112
168
|
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
113
169
|
content: 'Reply with exactly: abort-no-footer-setup',
|
|
@@ -137,7 +193,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
137
193
|
}
|
|
138
194
|
const beforeAbortMessages = await th.getMessages();
|
|
139
195
|
const baselineCount = beforeAbortMessages.length;
|
|
140
|
-
runtime.abortActiveRun('test-no-footer-on-abort');
|
|
196
|
+
void runtime.abortActiveRun('test-no-footer-on-abort');
|
|
141
197
|
for (let i = 0; i < 10; i++) {
|
|
142
198
|
await new Promise((resolve) => {
|
|
143
199
|
setTimeout(resolve, 20);
|
|
@@ -154,7 +210,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
154
210
|
Reply with exactly: abort-no-footer-setup
|
|
155
211
|
--- from: assistant (TestBot)
|
|
156
212
|
-# *using deterministic-provider/deterministic-v2*
|
|
157
|
-
|
|
213
|
+
ok
|
|
158
214
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
159
215
|
--- from: user (queue-advanced-tester)
|
|
160
216
|
SLOW_ABORT_MARKER run long response"
|
|
@@ -183,7 +239,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
183
239
|
if (!runtime) {
|
|
184
240
|
throw new Error('Expected runtime to exist for race abort scenario');
|
|
185
241
|
}
|
|
186
|
-
runtime.abortActiveRun('test-race-abort');
|
|
242
|
+
void runtime.abortActiveRun('test-race-abort');
|
|
187
243
|
await th.user(TEST_USER_ID).sendMessage({
|
|
188
244
|
content: raceFinalPrompt,
|
|
189
245
|
});
|
|
@@ -279,7 +335,7 @@ e2eTest('queue advanced: abort and retry', () => {
|
|
|
279
335
|
if (!runtime) {
|
|
280
336
|
throw new Error('Expected runtime to exist for forced-abort test');
|
|
281
337
|
}
|
|
282
|
-
runtime.abortActiveRun('force-abort-test');
|
|
338
|
+
void runtime.abortActiveRun('force-abort-test');
|
|
283
339
|
expect(await th.text()).toMatchInlineSnapshot(`
|
|
284
340
|
"--- from: user (queue-advanced-tester)
|
|
285
341
|
Reply with exactly: force-abort-setup
|
|
@@ -123,13 +123,13 @@ describe('queue advanced: action buttons', () => {
|
|
|
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');
|
|
@@ -196,7 +196,7 @@ describe('queue advanced: action buttons', () => {
|
|
|
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._
|
|
@@ -35,7 +35,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
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) => {
|
|
@@ -68,7 +68,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
68
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
|
}
|
|
@@ -122,12 +122,12 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
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) {
|
|
@@ -217,7 +217,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
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
|
|
@@ -226,7 +226,7 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
226
226
|
--- from: user (queue-advanced-tester)
|
|
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);
|
|
@@ -277,9 +277,9 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
277
277
|
-# *using deterministic-provider/deterministic-v2*
|
|
278
278
|
> running tool
|
|
279
279
|
|
|
280
|
-
┣ bash _echo tool-call-footer-test_
|
|
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);
|
|
@@ -249,7 +249,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
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**
|
|
@@ -263,7 +263,7 @@ describe('queue advanced: /model with interrupt recovery', () => {
|
|
|
263
263
|
--- from: user (queue-model-switch-tester)
|
|
264
264
|
Reply with exactly: model-switcher-followup
|
|
265
265
|
--- from: assistant (TestBot)
|
|
266
|
-
|
|
266
|
+
ok
|
|
267
267
|
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v3*"
|
|
268
268
|
`);
|
|
269
269
|
expect(footer).toBeDefined();
|
|
@@ -92,7 +92,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
92
92
|
-# *using deterministic-provider/deterministic-v2*
|
|
93
93
|
> requesting external read permission
|
|
94
94
|
|
|
95
|
-
┣ read *.zprofile*
|
|
95
|
+
-# ┣ read *.zprofile*
|
|
96
96
|
⚠️ **Permission Required**
|
|
97
97
|
**Type:** \`external_directory\`
|
|
98
98
|
Agent is accessing files outside the project. [Learn more](https://opencode.ai/docs/permissions/#external-directories)
|
|
@@ -100,7 +100,7 @@ describe('queue advanced: typing around permissions', () => {
|
|
|
100
100
|
✅ Permission **accepted**
|
|
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({
|