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.
Files changed (124) hide show
  1. package/dist/agent-model.e2e.test.js +27 -19
  2. package/dist/analytics.js +3 -1
  3. package/dist/btw-prefix-detection.js +9 -0
  4. package/dist/btw-prefix-detection.test.js +38 -1
  5. package/dist/cli-commands/send.js +5 -11
  6. package/dist/cli-runner.js +22 -10
  7. package/dist/cli-runner.test.js +34 -1
  8. package/dist/cli-send-thread.e2e.test.js +1 -1
  9. package/dist/commands/abort.js +16 -4
  10. package/dist/commands/btw.js +147 -39
  11. package/dist/commands/context-usage.js +47 -0
  12. package/dist/commands/context-usage.test.js +49 -0
  13. package/dist/commands/fork.js +32 -1
  14. package/dist/commands/model.js +69 -14
  15. package/dist/commands/new-worktree.js +5 -2
  16. package/dist/commands/queue.js +37 -30
  17. package/dist/commands/session.js +7 -2
  18. package/dist/db.test.js +17 -7
  19. package/dist/discord-bot.js +70 -47
  20. package/dist/discord-utils.js +15 -1
  21. package/dist/errors.js +10 -5
  22. package/dist/external-directory-permissions.e2e.test.js +4 -4
  23. package/dist/external-directory-project-deny.e2e.test.js +2 -2
  24. package/dist/gateway-proxy.e2e.test.js +5 -5
  25. package/dist/message-formatting.js +50 -15
  26. package/dist/message-formatting.test.js +12 -11
  27. package/dist/message-preprocessing.js +53 -75
  28. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  29. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  30. package/dist/queue-advanced-abort.e2e.test.js +62 -6
  31. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  32. package/dist/queue-advanced-footer.e2e.test.js +8 -8
  33. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  34. package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
  35. package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
  36. package/dist/queue-delete-message.e2e.test.js +146 -5
  37. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  38. package/dist/queue-interrupt-drain.e2e.test.js +24 -14
  39. package/dist/queue-question-select-drain.e2e.test.js +27 -27
  40. package/dist/runtime-lifecycle.e2e.test.js +6 -6
  41. package/dist/session-handler/agent-utils.js +16 -17
  42. package/dist/session-handler/event-stream-state.js +24 -16
  43. package/dist/session-handler/event-stream-state.test.js +281 -14
  44. package/dist/session-handler/thread-session-runtime.js +209 -128
  45. package/dist/sleep.e2e.test.js +6 -6
  46. package/dist/system-message.js +214 -392
  47. package/dist/system-message.test.js +204 -380
  48. package/dist/task-runner.js +3 -3
  49. package/dist/task-schedule.js +2 -2
  50. package/dist/test-utils.js +32 -6
  51. package/dist/thread-message-queue.e2e.test.js +132 -60
  52. package/dist/undo-redo.e2e.test.js +3 -3
  53. package/dist/voice-handler.js +2 -1
  54. package/dist/voice-message.e2e.test.js +22 -31
  55. package/dist/voice-routing-input.e2e.test.js +2 -2
  56. package/dist/voice-routing-question.e2e.test.js +59 -2
  57. package/dist/voice.js +306 -206
  58. package/dist/voice.test.js +161 -13
  59. package/dist/worktree-lifecycle.e2e.test.js +7 -7
  60. package/package.json +6 -9
  61. package/skills/critique/SKILL.md +4 -0
  62. package/skills/holocron/SKILL.md +0 -28
  63. package/skills/opensrc/SKILL.md +20 -13
  64. package/skills/playwriter/SKILL.md +1 -1
  65. package/src/agent-model.e2e.test.ts +28 -19
  66. package/src/analytics.ts +3 -1
  67. package/src/btw-prefix-detection.test.ts +39 -1
  68. package/src/btw-prefix-detection.ts +11 -0
  69. package/src/cli-commands/send.ts +11 -11
  70. package/src/cli-runner.test.ts +37 -0
  71. package/src/cli-runner.ts +26 -10
  72. package/src/cli-send-thread.e2e.test.ts +1 -1
  73. package/src/commands/abort.ts +20 -4
  74. package/src/commands/btw.ts +151 -41
  75. package/src/commands/context-usage.test.ts +57 -0
  76. package/src/commands/context-usage.ts +59 -0
  77. package/src/commands/fork.ts +34 -1
  78. package/src/commands/model.ts +81 -14
  79. package/src/commands/new-worktree.ts +5 -2
  80. package/src/commands/queue.ts +46 -30
  81. package/src/commands/session.ts +10 -2
  82. package/src/db.test.ts +18 -6
  83. package/src/discord-bot.ts +72 -56
  84. package/src/discord-utils.ts +17 -1
  85. package/src/errors.ts +12 -6
  86. package/src/external-directory-permissions.e2e.test.ts +4 -4
  87. package/src/external-directory-project-deny.e2e.test.ts +2 -2
  88. package/src/gateway-proxy.e2e.test.ts +5 -5
  89. package/src/message-formatting.test.ts +12 -11
  90. package/src/message-formatting.ts +52 -16
  91. package/src/message-preprocessing.ts +69 -91
  92. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  93. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  94. package/src/queue-advanced-abort.e2e.test.ts +73 -6
  95. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  96. package/src/queue-advanced-footer.e2e.test.ts +8 -8
  97. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  98. package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
  99. package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
  100. package/src/queue-delete-message.e2e.test.ts +164 -4
  101. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  102. package/src/queue-interrupt-drain.e2e.test.ts +24 -13
  103. package/src/queue-question-select-drain.e2e.test.ts +31 -28
  104. package/src/runtime-lifecycle.e2e.test.ts +6 -6
  105. package/src/session-handler/agent-utils.ts +25 -20
  106. package/src/session-handler/event-stream-state.test.ts +284 -13
  107. package/src/session-handler/event-stream-state.ts +37 -21
  108. package/src/session-handler/thread-runtime-state.ts +7 -0
  109. package/src/session-handler/thread-session-runtime.ts +255 -155
  110. package/src/sleep.e2e.test.ts +6 -6
  111. package/src/system-message.test.ts +216 -379
  112. package/src/system-message.ts +253 -389
  113. package/src/task-runner.ts +3 -3
  114. package/src/task-schedule.ts +2 -2
  115. package/src/test-utils.ts +55 -12
  116. package/src/thread-message-queue.e2e.test.ts +136 -63
  117. package/src/undo-redo.e2e.test.ts +3 -3
  118. package/src/voice-handler.ts +2 -1
  119. package/src/voice-message.e2e.test.ts +22 -33
  120. package/src/voice-routing-input.e2e.test.ts +2 -2
  121. package/src/voice-routing-question.e2e.test.ts +59 -2
  122. package/src/voice.test.ts +164 -14
  123. package/src/voice.ts +400 -279
  124. 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 SYSTEM_LINE_PREFIX = '-# ';
17
- export const STATUS_PREFIX = SYSTEM_LINE_PREFIX;
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 asSystemLine(text) {
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.startsWith(SYSTEM_LINE_PREFIX) || line.startsWith('-#'))
25
+ if (!line.trim())
25
26
  return line;
26
- return `${SYSTEM_LINE_PREFIX}${line}`;
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
- // Append non-text content so the model can see it
132
- const extras = [
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
- ].filter(Boolean);
137
- if (extras.length > 0) {
138
- const joined = extras.join('\n\n');
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().split('\n').length > 2)
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, asSystemLine, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, quotedTextFitsOneDiscordMessage, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js';
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('asSystemLine', () => {
69
- test('prefixes every line', () => {
70
- expect(asSystemLine('hello\nworld')).toMatchInlineSnapshot(`
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(asSystemLine('-# already marked')).toMatchInlineSnapshot(`"-# already marked"`);
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 still quotes two-line earlier text', () => {
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 });
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
- export function extractQueueSuffix(prompt) {
134
- if (!QUEUE_SUFFIX_RE.test(prompt)) {
135
- return { prompt, forceQueue: false };
136
- }
137
- return { prompt: prompt.replace(QUEUE_SUFFIX_RE, '').trimEnd(), forceQueue: true };
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
- : resolveMentions(message);
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
- // Extract queue suffix from raw message content BEFORE appending text
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
- prompt,
286
- images: fileAttachments,
287
- hasVoiceAttachment,
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 = resolveMentions(message);
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 qs = extractQueueSuffix(prompt);
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
- prompt: finalPrompt,
367
- images: fileAttachments,
368
- hasVoiceAttachment,
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 = resolveMentions(message);
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
- // Extract queue suffix from raw message content BEFORE appending text
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
- prompt,
429
- images: fileAttachments,
430
- hasVoiceAttachment,
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
- > ok
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
- > ok
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
- > action-buttons-click-continued
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
- > ok
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
- > ok
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
- > ok
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
- > ok
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
- > ok
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
- > ok
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
- > ok
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
- > tool call completed
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
- > ok
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
- > ok
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
- > permission-flow-done
103
+ permission-flow-done
104
104
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
105
105
  `);
106
106
  const timeline = await th.text({