kimaki 0.30.0 → 0.30.1

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 (46) hide show
  1. package/dist/cli-commands/send.js +3 -9
  2. package/dist/commands/abort.js +16 -4
  3. package/dist/commands/queue.js +7 -7
  4. package/dist/discord-bot.js +5 -5
  5. package/dist/discord-utils.js +15 -1
  6. package/dist/external-directory-permissions.e2e.test.js +2 -2
  7. package/dist/external-directory-project-deny.e2e.test.js +1 -1
  8. package/dist/message-formatting.js +16 -6
  9. package/dist/message-formatting.test.js +10 -9
  10. package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
  11. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
  12. package/dist/queue-advanced-abort.e2e.test.js +61 -5
  13. package/dist/queue-advanced-footer.e2e.test.js +1 -1
  14. package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
  15. package/dist/session-handler/event-stream-state.js +15 -15
  16. package/dist/session-handler/event-stream-state.test.js +152 -11
  17. package/dist/session-handler/thread-session-runtime.js +41 -28
  18. package/dist/sleep.e2e.test.js +2 -2
  19. package/dist/task-runner.js +3 -3
  20. package/dist/task-schedule.js +2 -2
  21. package/dist/thread-message-queue.e2e.test.js +1 -1
  22. package/dist/undo-redo.e2e.test.js +1 -1
  23. package/package.json +6 -6
  24. package/skills/holocron/SKILL.md +0 -28
  25. package/src/cli-commands/send.ts +3 -9
  26. package/src/commands/abort.ts +20 -4
  27. package/src/commands/queue.ts +7 -7
  28. package/src/discord-bot.ts +5 -5
  29. package/src/discord-utils.ts +17 -1
  30. package/src/external-directory-permissions.e2e.test.ts +2 -2
  31. package/src/external-directory-project-deny.e2e.test.ts +1 -1
  32. package/src/message-formatting.test.ts +10 -9
  33. package/src/message-formatting.ts +15 -6
  34. package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
  35. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
  36. package/src/queue-advanced-abort.e2e.test.ts +72 -5
  37. package/src/queue-advanced-footer.e2e.test.ts +1 -1
  38. package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
  39. package/src/session-handler/event-stream-state.test.ts +155 -11
  40. package/src/session-handler/event-stream-state.ts +17 -20
  41. package/src/session-handler/thread-session-runtime.ts +44 -32
  42. package/src/sleep.e2e.test.ts +2 -2
  43. package/src/task-runner.ts +3 -3
  44. package/src/task-schedule.ts +2 -2
  45. package/src/thread-message-queue.e2e.test.ts +1 -1
  46. package/src/undo-redo.e2e.test.ts +1 -1
@@ -6,7 +6,6 @@
6
6
  import { goke } from 'goke';
7
7
  import { z } from 'zod';
8
8
  import { note } from '@clack/prompts';
9
- import YAML from 'yaml';
10
9
  import * as errore from 'errore';
11
10
  import { Events, ActivityType, Routes } from 'discord.js';
12
11
  import path from 'node:path';
@@ -24,7 +23,7 @@ import { WORKTREE_PREFIX } from '../commands/merge-worktree.js';
24
23
  import { QUEUE_PREFIX } from '../message-formatting.js';
25
24
  import { buildOpencodeEventLogLine } from '../session-handler/opencode-session-event-log.js';
26
25
  import { createDiscordRest } from '../discord-urls.js';
27
- import { archiveThread, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
26
+ import { archiveThread, buildThreadStartEmbeds, ensureThreadMember, uploadFilesToDiscord, stripMentions } from '../discord-utils.js';
28
27
  import { setDataDir, setProjectsDir, getDataDir, getProjectsDir } from '../config.js';
29
28
  import { execAsync, resolveSessionWorkingDirectory, isGitRepositoryRoot } from '../worktrees.js';
30
29
  import { upgrade, getCurrentVersion } from '../upgrade.js';
@@ -405,12 +404,7 @@ cli
405
404
  ...(options.injectionGuard?.length ? { injectionGuardPatterns: options.injectionGuard } : {}),
406
405
  ...(options.parentSession && { parentSessionId: options.parentSession }),
407
406
  };
408
- const promptEmbed = [
409
- {
410
- color: 0x2b2d31,
411
- footer: { text: YAML.stringify(threadPromptMarker) },
412
- },
413
- ];
407
+ const promptEmbed = await buildThreadStartEmbeds(threadPromptMarker);
414
408
  // Prefix the prompt so it's clear who sent it (matches /queue format).
415
409
  // Use a newline between prefix and prompt so leading /command
416
410
  // detection can find the command on its own line.
@@ -584,7 +578,7 @@ cli
584
578
  ...(options.parentSession && { parentSessionId: options.parentSession }),
585
579
  };
586
580
  const autoStartEmbed = embedMarker
587
- ? [{ color: 0x2b2d31, footer: { text: YAML.stringify(embedMarker) } }]
581
+ ? await buildThreadStartEmbeds(embedMarker)
588
582
  : undefined;
589
583
  const starterMessage = await sendDiscordMessageWithOptionalAttachment({
590
584
  channelId,
@@ -1,6 +1,6 @@
1
1
  // /abort command - Abort the current OpenCode request in this thread.
2
2
  import { ChannelType, MessageFlags, } from 'discord.js';
3
- import { cancelSessionSleepForThread, getThreadSession } from '../database.js';
3
+ import { cancelSessionSleepForThread, deleteThreadQueueItems, getThreadSession, } from '../database.js';
4
4
  import { getOpencodeClient, initializeOpencodeForDirectory } from '../opencode.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getRuntime } from '../session-handler/thread-session-runtime.js';
@@ -45,12 +45,21 @@ export async function handleAbortCommand({ command, }) {
45
45
  // through enqueueIncoming, so cancel any pending sleep here too. Otherwise the
46
46
  // wake would still fire later and restart a session the user just stopped.
47
47
  await cancelSessionSleepForThread({ threadId: channel.id });
48
- // abortActiveRun delegates to session.abort(), run settlement stays event-driven.
48
+ // abortActiveRun delegates to session.abort() and clears the /queue, so
49
+ // queued messages are not sent after abort or restored after a restart.
49
50
  const runtime = getRuntime(channel.id);
51
+ let clearedCount = 0;
50
52
  if (runtime) {
51
- runtime.abortActiveRun('user-requested');
53
+ const cleared = await runtime.abortActiveRun('user-requested');
54
+ clearedCount = cleared.length;
52
55
  }
53
56
  else {
57
+ const deleteResult = await deleteThreadQueueItems(channel.id).catch((error) => {
58
+ return new Error('Failed to clear persisted queue', { cause: error });
59
+ });
60
+ if (deleteResult instanceof Error) {
61
+ logger.error(`[ABORT] ${deleteResult.message}:`, deleteResult.cause);
62
+ }
54
63
  // No runtime but session exists — fall back to direct API abort
55
64
  const serverResult = await initializeOpencodeForDirectory(projectDirectory);
56
65
  if (serverResult instanceof Error) {
@@ -67,6 +76,9 @@ export async function handleAbortCommand({ command, }) {
67
76
  logger.error('[ABORT] API abort failed:', error);
68
77
  }
69
78
  }
70
- await command.editReply('Request **aborted**');
79
+ const queueNote = clearedCount > 0
80
+ ? `, cleared ${clearedCount} queued message${clearedCount > 1 ? 's' : ''}`
81
+ : '';
82
+ await command.editReply(`Request **aborted**${queueNote}`);
71
83
  logger.log(`Session ${sessionId} aborted by user`);
72
84
  }
@@ -5,7 +5,7 @@ import { deleteThreadQueueItem, getThreadSession } from '../database.js';
5
5
  import { resolveWorkingDirectory, SILENT_MESSAGE_FLAGS, } from '../discord-utils.js';
6
6
  import { getOrCreateRuntime, getRuntime, } from '../session-handler/thread-session-runtime.js';
7
7
  import { createLogger, LogPrefix } from '../logger.js';
8
- import { asSystemLine, QUEUE_PREFIX } from '../message-formatting.js';
8
+ import { asSubtext, QUEUE_PREFIX } from '../message-formatting.js';
9
9
  import { store } from '../store.js';
10
10
  const logger = createLogger(LogPrefix.QUEUE);
11
11
  export const QUEUE_REMOVE_CUSTOM_ID_PREFIX = 'queue_remove:';
@@ -75,7 +75,7 @@ export async function handleQueueRemoveButton(interaction) {
75
75
  : await deleteThreadQueueItemWithoutRuntime(parsed.queueId);
76
76
  if (!removed) {
77
77
  await interaction.editReply({
78
- content: asSystemLine('Queued message is no longer in the queue'),
78
+ content: asSubtext('Queued message is no longer in the queue'),
79
79
  components: [],
80
80
  });
81
81
  return;
@@ -84,7 +84,7 @@ export async function handleQueueRemoveButton(interaction) {
84
84
  ? `/${removed.command.name}`
85
85
  : removed.prompt.slice(0, 120);
86
86
  await interaction.editReply({
87
- content: asSystemLine(`Removed queued message: ${label}`),
87
+ content: asSubtext(`Removed queued message: ${label}`),
88
88
  components: [],
89
89
  });
90
90
  logger.log(`[QUEUE] User ${interaction.user.displayName} removed queued item ${parsed.queueId} in thread ${parsed.threadId}`);
@@ -145,7 +145,7 @@ export async function handleQueueCommand({ command, appId, }) {
145
145
  mode: 'local-queue',
146
146
  });
147
147
  if (enqueueResult.queued && enqueueResult.queueId) {
148
- const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
148
+ const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
149
149
  await command.reply({
150
150
  content: responseText,
151
151
  components: [
@@ -205,7 +205,7 @@ export async function handleClearQueueCommand({ command, }) {
205
205
  return;
206
206
  }
207
207
  await command.reply({
208
- content: asSystemLine(`Cleared queued message at position ${position}`),
208
+ content: asSubtext(`Cleared queued message at position ${position}`),
209
209
  flags: SILENT_MESSAGE_FLAGS,
210
210
  });
211
211
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queued position ${position} in thread ${channel.id}`);
@@ -223,7 +223,7 @@ export async function handleClearQueueCommand({ command, }) {
223
223
  list = list.slice(0, 597) + '...';
224
224
  }
225
225
  await command.reply({
226
- content: asSystemLine(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
226
+ content: asSubtext(`Cleared ${cleared.length} queued message${cleared.length > 1 ? 's' : ''}:\n${list}`),
227
227
  flags: SILENT_MESSAGE_FLAGS,
228
228
  });
229
229
  logger.log(`[QUEUE] User ${command.user.displayName} cleared queue in thread ${channel.id}`);
@@ -299,7 +299,7 @@ export async function handleQueueCommandCommand({ command, appId, }) {
299
299
  mode: 'local-queue',
300
300
  });
301
301
  if (enqueueResult.queued && enqueueResult.queueId) {
302
- const responseText = asSystemLine(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
302
+ const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
303
303
  await command.reply({
304
304
  content: responseText,
305
305
  components: [
@@ -13,7 +13,7 @@ import { stopOpencodeServer, } from './opencode.js';
13
13
  import { formatAutoWorktreeName, createWorktreeInBackground, worktreeCreatingMessage } from './commands/new-worktree.js';
14
14
  import { resolveSessionWorkingDirectory, git, isGitRepositoryRoot } from './worktrees.js';
15
15
  import { WORKTREE_PREFIX } from './commands/merge-worktree.js';
16
- import { asSystemLine, STATUS_PREFIX } from './message-formatting.js';
16
+ import { asSubtext } from './message-formatting.js';
17
17
  import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord, sendThreadMessage, SILENT_MESSAGE_FLAGS, NOTIFY_MESSAGE_FLAGS, reactToThread, stripMentions, hasKimakiBotPermission, hasNoKimakiRole, resolveGuildMessageMember, } from './discord-utils.js';
18
18
  import { getOpencodeSystemMessage, isInjectedPromptMarker, } from './system-message.js';
19
19
  import YAML from 'yaml';
@@ -742,7 +742,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
742
742
  });
743
743
  // Notify when a voice message was queued instead of sent immediately
744
744
  if (enqueueResult.queued && enqueueResult.position) {
745
- await sendThreadMessage(thread, asSystemLine(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
745
+ await sendThreadMessage(thread, asSubtext(`Queued at position ${enqueueResult.position}. Edit or delete your message to update the queue`));
746
746
  }
747
747
  }
748
748
  if (channel.type === ChannelType.GuildText) {
@@ -955,11 +955,11 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
955
955
  const displayName = message.member?.displayName ?? message.author.displayName;
956
956
  if (result.removed) {
957
957
  discordLogger.log(`[MESSAGE_EDIT] Removed queued message ${message.id} in thread ${channel.id}`);
958
- await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** removed message from queue`);
958
+ await sendThreadMessage(channel, asSubtext(`**${displayName}** removed message from queue`));
959
959
  }
960
960
  else {
961
961
  discordLogger.log(`[MESSAGE_EDIT] Updated queued message ${message.id} in thread ${channel.id}`);
962
- await sendThreadMessage(channel, `${STATUS_PREFIX}**${displayName}** edited queued message`);
962
+ await sendThreadMessage(channel, asSubtext(`**${displayName}** edited queued message`));
963
963
  }
964
964
  }
965
965
  }
@@ -982,7 +982,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
982
982
  if (!removed)
983
983
  return;
984
984
  discordLogger.log(`[MESSAGE_DELETE] Removed queued message ${message.id} in thread ${channel.id}`);
985
- await sendThreadMessage(channel, `${STATUS_PREFIX}**${removed.username}** removed message from queue`);
985
+ await sendThreadMessage(channel, asSubtext(`**${removed.username}** removed message from queue`));
986
986
  }
987
987
  catch (error) {
988
988
  discordLogger.error('Error handling message delete:', error instanceof Error ? error.stack : String(error));
@@ -10,8 +10,9 @@ import { discordApiUrl } from './discord-urls.js';
10
10
  import { Lexer } from 'marked';
11
11
  import { splitTablesFromMarkdown } from './format-tables.js';
12
12
  import { sessionPartContent, shouldLeadWithBlankLine, } from './message-formatting.js';
13
- import { getChannelDirectory, getThreadWorktreeOrWorkspace } from './database.js';
13
+ import { getChannelDirectory, getThreadIdBySessionId, getThreadWorktreeOrWorkspace } from './database.js';
14
14
  import { DiscordOperationError } from './errors.js';
15
+ import YAML from 'yaml';
15
16
  import { limitHeadingDepth } from './limit-heading-depth.js';
16
17
  import { unnestCodeBlocksFromLists } from './unnest-code-blocks.js';
17
18
  import { createLogger, LogPrefix } from './logger.js';
@@ -20,6 +21,19 @@ import mime from 'mime';
20
21
  import fs from 'node:fs';
21
22
  import path from 'node:path';
22
23
  const discordLogger = createLogger(LogPrefix.DISCORD);
24
+ /** Starter embed with the YAML marker. Links the parent thread when the parent session is known locally. */
25
+ export async function buildThreadStartEmbeds(marker) {
26
+ const parentThreadId = marker.parentSessionId
27
+ ? await getThreadIdBySessionId(marker.parentSessionId)
28
+ : undefined;
29
+ return [
30
+ {
31
+ color: 0x2b2d31,
32
+ ...(parentThreadId && { description: `Parent thread: <#${parentThreadId}>` }),
33
+ footer: { text: YAML.stringify(marker) },
34
+ },
35
+ ];
36
+ }
23
37
  /**
24
38
  * Centralized permission check for Kimaki bot access.
25
39
  * Returns true if the member has permission to use the bot:
@@ -67,7 +67,7 @@ describe('external directory permissions', () => {
67
67
  -# *using deterministic-provider/deterministic-v2*
68
68
  > reading external directory
69
69
 
70
- ┣ read *probe.txt*
70
+ -# ┣ read *probe.txt*
71
71
 
72
72
  > external-directory-probe-done
73
73
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
@@ -76,7 +76,7 @@ describe('external directory permissions', () => {
76
76
  --- from: assistant (TestBot)
77
77
  > reading external directory
78
78
 
79
- ┣ read *probe.txt*
79
+ -# ┣ read *probe.txt*
80
80
 
81
81
  > external-directory-probe-done
82
82
  -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
@@ -57,7 +57,7 @@ describe('external directory project deny', () => {
57
57
  -# *using deterministic-provider/deterministic-v2*
58
58
  > reading external directory
59
59
 
60
- ┣ read *probe.txt*
60
+ -# ┣ read *probe.txt*
61
61
 
62
62
  > external-directory-probe-denied"
63
63
  `);
@@ -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
  /**
@@ -636,9 +639,16 @@ export function formatTaskToolTitle(part) {
636
639
  return '';
637
640
  const subagentType = part.state.input?.subagent_type;
638
641
  const agent = typeof subagentType === 'string' ? subagentType : 'task';
639
- return `${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`;
642
+ return asSubtext(`${TOOL_PREFIX}${escapeInlineMarkdown(agent)} **${escapeInlineMarkdown(title)}**`);
640
643
  }
644
+ // Non-text parts (tools, thinking, files) render as Discord subtext so they look dimmer than text.
641
645
  export function formatPart(part, prefix) {
646
+ const formatted = formatPartBody(part, prefix);
647
+ if (!formatted || part.type === 'text')
648
+ return formatted;
649
+ return asSubtext(formatted);
650
+ }
651
+ function formatPartBody(part, prefix) {
642
652
  const pfx = prefix ? `${prefix} ⋅ ` : '';
643
653
  if (part.type === 'text') {
644
654
  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', () => {
@@ -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', () => {
@@ -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);
@@ -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
@@ -277,7 +277,7 @@ 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*"
@@ -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)
@@ -597,9 +597,6 @@ function hasPruneBetween({ events, sessionId, fromIndex, toIndex, }) {
597
597
  }
598
598
  return false;
599
599
  }
600
- function promptInputTokens(tokens) {
601
- return tokens.input + tokens.cache.read + tokens.cache.write;
602
- }
603
600
  function getCompletedAssistantAt({ events, sessionId, messageId, upToIndex, }) {
604
601
  for (let i = upToIndex; i >= 0; i--) {
605
602
  const event = events[i]?.event;
@@ -629,7 +626,8 @@ function isComparableCacheAssistant(message) {
629
626
  }
630
627
  return true;
631
628
  }
632
- // Same-model cache drop vs the previous completed assistant. Errors and compaction summaries block the pair.
629
+ // Same-model cache drop vs the previous completed assistant. Aborted/errored replies are skipped,
630
+ // compaction summaries and pruned tool output block the pair since they rewrite the prompt.
633
631
  export function getPromptCacheClear({ events, sessionId, currentMessageId, upToIndex, }) {
634
632
  const end = upToIndex ?? events.length - 1;
635
633
  const current = getCompletedAssistantAt({
@@ -658,13 +656,13 @@ export function getPromptCacheClear({ events, sessionId, currentMessageId, upToI
658
656
  continue;
659
657
  }
660
658
  seen.add(info.id);
661
- if (!isComparableCacheAssistant(info) || !info.tokens) {
659
+ if (!isUserFacingAssistantMessage(info)) {
662
660
  return undefined;
663
661
  }
664
- if (info.modelID !== current.modelID || info.providerID !== current.providerID) {
665
- return undefined;
662
+ if (!isComparableCacheAssistant(info) || !info.tokens) {
663
+ continue;
666
664
  }
667
- if (info.tokens.cache.read < MIN_PROMPT_CACHE_READ_TO_TRACK) {
665
+ if (info.modelID !== current.modelID || info.providerID !== current.providerID) {
668
666
  return undefined;
669
667
  }
670
668
  if (hasPruneBetween({
@@ -675,17 +673,19 @@ export function getPromptCacheClear({ events, sessionId, currentMessageId, upToI
675
673
  })) {
676
674
  return undefined;
677
675
  }
678
- if (current.tokens.cache.read > info.tokens.cache.read * PROMPT_CACHE_DROP_RATIO) {
676
+ // Anthropic reports a fresh cache as write only, so read alone misses the turn after a miss.
677
+ const previousCached = info.tokens.cache.read + info.tokens.cache.write;
678
+ const currentPrompt = current.tokens.input + current.tokens.cache.read + current.tokens.cache.write;
679
+ // A reverted (shorter) prompt can only reuse its own length from cache.
680
+ const expectedCacheRead = Math.min(previousCached, currentPrompt);
681
+ if (expectedCacheRead < MIN_PROMPT_CACHE_READ_TO_TRACK) {
679
682
  return undefined;
680
683
  }
681
- const previousPrompt = promptInputTokens(info.tokens);
682
- const currentPrompt = promptInputTokens(current.tokens);
683
- // Pruning blanks old tool output and shrinks the next prompt. That is not a cache clear.
684
- if (currentPrompt < previousPrompt) {
684
+ if (current.tokens.cache.read > expectedCacheRead * PROMPT_CACHE_DROP_RATIO) {
685
685
  return undefined;
686
686
  }
687
687
  return {
688
- previousCacheRead: info.tokens.cache.read,
688
+ expectedCacheRead,
689
689
  currentCacheRead: current.tokens.cache.read,
690
690
  previousMessageId: info.id,
691
691
  currentMessageId: current.id,
@@ -702,7 +702,7 @@ export function formatCompactTokenCount(count) {
702
702
  return String(count);
703
703
  }
704
704
  export function formatPromptCacheClearMessage(clear, systemDiff) {
705
- const tokens = `prompt cache missed (${formatCompactTokenCount(clear.previousCacheRead)} → ${formatCompactTokenCount(clear.currentCacheRead)})`;
705
+ const tokens = `prompt cache missed (${formatCompactTokenCount(clear.expectedCacheRead)} → ${formatCompactTokenCount(clear.currentCacheRead)})`;
706
706
  if (!systemDiff || (systemDiff.additions === 0 && systemDiff.deletions === 0)) {
707
707
  return tokens;
708
708
  }