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.
- package/dist/cli-commands/send.js +3 -9
- package/dist/commands/abort.js +16 -4
- package/dist/commands/queue.js +7 -7
- package/dist/discord-bot.js +5 -5
- package/dist/discord-utils.js +15 -1
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/message-formatting.js +16 -6
- package/dist/message-formatting.test.js +10 -9
- 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 +61 -5
- package/dist/queue-advanced-footer.e2e.test.js +1 -1
- package/dist/queue-advanced-permissions-typing.e2e.test.js +1 -1
- package/dist/session-handler/event-stream-state.js +15 -15
- package/dist/session-handler/event-stream-state.test.js +152 -11
- package/dist/session-handler/thread-session-runtime.js +41 -28
- package/dist/sleep.e2e.test.js +2 -2
- package/dist/task-runner.js +3 -3
- package/dist/task-schedule.js +2 -2
- package/dist/thread-message-queue.e2e.test.js +1 -1
- package/dist/undo-redo.e2e.test.js +1 -1
- package/package.json +6 -6
- package/skills/holocron/SKILL.md +0 -28
- package/src/cli-commands/send.ts +3 -9
- package/src/commands/abort.ts +20 -4
- package/src/commands/queue.ts +7 -7
- package/src/discord-bot.ts +5 -5
- package/src/discord-utils.ts +17 -1
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/message-formatting.test.ts +10 -9
- package/src/message-formatting.ts +15 -6
- 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 +72 -5
- package/src/queue-advanced-footer.e2e.test.ts +1 -1
- package/src/queue-advanced-permissions-typing.e2e.test.ts +1 -1
- package/src/session-handler/event-stream-state.test.ts +155 -11
- package/src/session-handler/event-stream-state.ts +17 -20
- package/src/session-handler/thread-session-runtime.ts +44 -32
- package/src/sleep.e2e.test.ts +2 -2
- package/src/task-runner.ts +3 -3
- package/src/task-schedule.ts +2 -2
- package/src/thread-message-queue.e2e.test.ts +1 -1
- 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
|
-
?
|
|
581
|
+
? await buildThreadStartEmbeds(embedMarker)
|
|
588
582
|
: undefined;
|
|
589
583
|
const starterMessage = await sendDiscordMessageWithOptionalAttachment({
|
|
590
584
|
channelId,
|
package/dist/commands/abort.js
CHANGED
|
@@ -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()
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/queue.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
302
|
+
const responseText = asSubtext(`Queued message${enqueueResult.position ? ` (position ${enqueueResult.position})` : ''}`);
|
|
303
303
|
await command.reply({
|
|
304
304
|
content: responseText,
|
|
305
305
|
components: [
|
package/dist/discord-bot.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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));
|
package/dist/discord-utils.js
CHANGED
|
@@ -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*"
|
|
@@ -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
|
/**
|
|
@@ -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,
|
|
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', () => {
|
|
@@ -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.
|
|
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 (!
|
|
659
|
+
if (!isUserFacingAssistantMessage(info)) {
|
|
662
660
|
return undefined;
|
|
663
661
|
}
|
|
664
|
-
if (info
|
|
665
|
-
|
|
662
|
+
if (!isComparableCacheAssistant(info) || !info.tokens) {
|
|
663
|
+
continue;
|
|
666
664
|
}
|
|
667
|
-
if (info.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|