@brianli/kimaki 0.4.72-brianli.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/bin.js +2 -0
- package/dist/ai-tool-to-genai.js +233 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/ai-tool.js +6 -0
- package/dist/bin.js +87 -0
- package/dist/bot-token.js +121 -0
- package/dist/bot-token.test.js +134 -0
- package/dist/channel-management.js +101 -0
- package/dist/cli-parsing.test.js +89 -0
- package/dist/cli.js +2529 -0
- package/dist/commands/abort.js +82 -0
- package/dist/commands/action-buttons.js +257 -0
- package/dist/commands/add-project.js +114 -0
- package/dist/commands/agent.js +291 -0
- package/dist/commands/ask-question.js +223 -0
- package/dist/commands/compact.js +120 -0
- package/dist/commands/context-usage.js +140 -0
- package/dist/commands/create-new-project.js +118 -0
- package/dist/commands/diff.js +128 -0
- package/dist/commands/file-upload.js +275 -0
- package/dist/commands/fork.js +217 -0
- package/dist/commands/gemini-apikey.js +70 -0
- package/dist/commands/login.js +490 -0
- package/dist/commands/mention-mode.js +51 -0
- package/dist/commands/merge-worktree.js +124 -0
- package/dist/commands/model.js +694 -0
- package/dist/commands/permissions.js +163 -0
- package/dist/commands/queue.js +217 -0
- package/dist/commands/remove-project.js +115 -0
- package/dist/commands/restart-opencode-server.js +116 -0
- package/dist/commands/resume.js +159 -0
- package/dist/commands/run-command.js +79 -0
- package/dist/commands/session-id.js +78 -0
- package/dist/commands/session.js +192 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +159 -0
- package/dist/commands/unset-model.js +152 -0
- package/dist/commands/upgrade.js +42 -0
- package/dist/commands/user-command.js +148 -0
- package/dist/commands/verbosity.js +60 -0
- package/dist/commands/worktree-settings.js +50 -0
- package/dist/commands/worktree.js +299 -0
- package/dist/condense-memory.js +33 -0
- package/dist/config.js +110 -0
- package/dist/database.js +1050 -0
- package/dist/db.js +159 -0
- package/dist/db.test.js +49 -0
- package/dist/discord-api.js +28 -0
- package/dist/discord-auth.js +231 -0
- package/dist/discord-auth.test.js +80 -0
- package/dist/discord-bot.js +997 -0
- package/dist/discord-utils.js +560 -0
- package/dist/discord-utils.test.js +115 -0
- package/dist/errors.js +167 -0
- package/dist/escape-backticks.test.js +429 -0
- package/dist/format-tables.js +122 -0
- package/dist/format-tables.test.js +199 -0
- package/dist/forum-sync/config.js +79 -0
- package/dist/forum-sync/discord-operations.js +154 -0
- package/dist/forum-sync/index.js +5 -0
- package/dist/forum-sync/markdown.js +117 -0
- package/dist/forum-sync/sync-to-discord.js +417 -0
- package/dist/forum-sync/sync-to-files.js +190 -0
- package/dist/forum-sync/types.js +53 -0
- package/dist/forum-sync/watchers.js +307 -0
- package/dist/gateway-consumer.js +232 -0
- package/dist/gateway-consumer.test.js +18 -0
- package/dist/genai-worker-wrapper.js +111 -0
- package/dist/genai-worker.js +311 -0
- package/dist/genai.js +232 -0
- package/dist/generated/browser.js +17 -0
- package/dist/generated/client.js +35 -0
- package/dist/generated/commonInputTypes.js +10 -0
- package/dist/generated/enums.js +30 -0
- package/dist/generated/internal/class.js +41 -0
- package/dist/generated/internal/prismaNamespace.js +239 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +209 -0
- package/dist/generated/models/bot_api_keys.js +1 -0
- package/dist/generated/models/bot_tokens.js +1 -0
- package/dist/generated/models/channel_agents.js +1 -0
- package/dist/generated/models/channel_directories.js +1 -0
- package/dist/generated/models/channel_mention_mode.js +1 -0
- package/dist/generated/models/channel_models.js +1 -0
- package/dist/generated/models/channel_verbosity.js +1 -0
- package/dist/generated/models/channel_worktrees.js +1 -0
- package/dist/generated/models/forum_sync_configs.js +1 -0
- package/dist/generated/models/global_models.js +1 -0
- package/dist/generated/models/ipc_requests.js +1 -0
- package/dist/generated/models/part_messages.js +1 -0
- package/dist/generated/models/scheduled_tasks.js +1 -0
- package/dist/generated/models/session_agents.js +1 -0
- package/dist/generated/models/session_models.js +1 -0
- package/dist/generated/models/session_start_sources.js +1 -0
- package/dist/generated/models/thread_sessions.js +1 -0
- package/dist/generated/models/thread_worktrees.js +1 -0
- package/dist/generated/models.js +1 -0
- package/dist/heap-monitor.js +95 -0
- package/dist/hrana-server.js +416 -0
- package/dist/hrana-server.test.js +368 -0
- package/dist/image-utils.js +112 -0
- package/dist/interaction-handler.js +327 -0
- package/dist/ipc-polling.js +251 -0
- package/dist/kimaki-digital-twin.e2e.test.js +165 -0
- package/dist/limit-heading-depth.js +25 -0
- package/dist/limit-heading-depth.test.js +105 -0
- package/dist/logger.js +160 -0
- package/dist/markdown.js +342 -0
- package/dist/markdown.test.js +253 -0
- package/dist/message-formatting.js +433 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode-plugin-loading.e2e.test.js +91 -0
- package/dist/opencode-plugin.js +536 -0
- package/dist/opencode-plugin.test.js +98 -0
- package/dist/opencode.js +409 -0
- package/dist/privacy-sanitizer.js +105 -0
- package/dist/runtime-mode.js +51 -0
- package/dist/runtime-mode.test.js +115 -0
- package/dist/sentry.js +127 -0
- package/dist/session-handler/state.js +151 -0
- package/dist/session-handler.js +1874 -0
- package/dist/session-search.js +100 -0
- package/dist/session-search.test.js +40 -0
- package/dist/startup-service.js +153 -0
- package/dist/system-message.js +499 -0
- package/dist/task-runner.js +282 -0
- package/dist/task-schedule.js +191 -0
- package/dist/task-schedule.test.js +71 -0
- package/dist/thinking-utils.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +781 -0
- package/dist/tools.js +359 -0
- package/dist/unnest-code-blocks.js +136 -0
- package/dist/unnest-code-blocks.test.js +641 -0
- package/dist/upgrade.js +114 -0
- package/dist/utils.js +109 -0
- package/dist/voice-handler.js +606 -0
- package/dist/voice.js +304 -0
- package/dist/voice.test.js +187 -0
- package/dist/wait-session.js +94 -0
- package/dist/worker-types.js +4 -0
- package/dist/worktree-utils.js +727 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +82 -0
- package/schema.prisma +246 -0
- package/skills/batch/SKILL.md +87 -0
- package/skills/critique/SKILL.md +129 -0
- package/skills/errore/SKILL.md +589 -0
- package/skills/goke/.prettierrc +5 -0
- package/skills/goke/CHANGELOG.md +40 -0
- package/skills/goke/LICENSE +21 -0
- package/skills/goke/README.md +666 -0
- package/skills/goke/SKILL.md +458 -0
- package/skills/goke/package.json +43 -0
- package/skills/goke/src/__test__/coerce.test.ts +411 -0
- package/skills/goke/src/__test__/index.test.ts +1798 -0
- package/skills/goke/src/__test__/types.test-d.ts +111 -0
- package/skills/goke/src/coerce.ts +547 -0
- package/skills/goke/src/goke.ts +1362 -0
- package/skills/goke/src/index.ts +16 -0
- package/skills/goke/src/mri.ts +164 -0
- package/skills/goke/tsconfig.json +15 -0
- package/skills/jitter/EDITOR.md +219 -0
- package/skills/jitter/EXPORT-INTERNALS.md +309 -0
- package/skills/jitter/SKILL.md +158 -0
- package/skills/jitter/jitter-clipboard.json +1042 -0
- package/skills/jitter/package.json +14 -0
- package/skills/jitter/tsconfig.json +15 -0
- package/skills/jitter/utils/actions.ts +212 -0
- package/skills/jitter/utils/export.ts +114 -0
- package/skills/jitter/utils/index.ts +141 -0
- package/skills/jitter/utils/snapshot.ts +154 -0
- package/skills/jitter/utils/traverse.ts +246 -0
- package/skills/jitter/utils/types.ts +279 -0
- package/skills/jitter/utils/wait.ts +133 -0
- package/skills/playwriter/SKILL.md +31 -0
- package/skills/security-review/SKILL.md +208 -0
- package/skills/simplify/SKILL.md +58 -0
- package/skills/termcast/SKILL.md +945 -0
- package/skills/tuistory/SKILL.md +250 -0
- package/skills/zustand-centralized-state/SKILL.md +582 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +41 -0
- package/src/__snapshots__/first-session-no-info.md +17 -0
- package/src/__snapshots__/first-session-with-info.md +23 -0
- package/src/__snapshots__/session-1.md +17 -0
- package/src/__snapshots__/session-2.md +5871 -0
- package/src/__snapshots__/session-3.md +17 -0
- package/src/__snapshots__/session-with-tools.md +5871 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +282 -0
- package/src/ai-tool.ts +39 -0
- package/src/bin.ts +108 -0
- package/src/bot-token.test.ts +171 -0
- package/src/bot-token.ts +159 -0
- package/src/channel-management.ts +172 -0
- package/src/cli-parsing.test.ts +132 -0
- package/src/cli.ts +3605 -0
- package/src/commands/abort.ts +112 -0
- package/src/commands/action-buttons.ts +376 -0
- package/src/commands/add-project.ts +152 -0
- package/src/commands/agent.ts +404 -0
- package/src/commands/ask-question.ts +330 -0
- package/src/commands/compact.ts +157 -0
- package/src/commands/context-usage.ts +199 -0
- package/src/commands/create-new-project.ts +179 -0
- package/src/commands/diff.ts +165 -0
- package/src/commands/file-upload.ts +389 -0
- package/src/commands/fork.ts +320 -0
- package/src/commands/gemini-apikey.ts +104 -0
- package/src/commands/login.ts +634 -0
- package/src/commands/mention-mode.ts +77 -0
- package/src/commands/merge-worktree.ts +177 -0
- package/src/commands/model.ts +961 -0
- package/src/commands/permissions.ts +261 -0
- package/src/commands/queue.ts +296 -0
- package/src/commands/remove-project.ts +155 -0
- package/src/commands/restart-opencode-server.ts +162 -0
- package/src/commands/resume.ts +242 -0
- package/src/commands/run-command.ts +123 -0
- package/src/commands/session-id.ts +109 -0
- package/src/commands/session.ts +250 -0
- package/src/commands/share.ts +106 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +221 -0
- package/src/commands/unset-model.ts +189 -0
- package/src/commands/upgrade.ts +52 -0
- package/src/commands/user-command.ts +193 -0
- package/src/commands/verbosity.ts +88 -0
- package/src/commands/worktree-settings.ts +79 -0
- package/src/commands/worktree.ts +431 -0
- package/src/condense-memory.ts +36 -0
- package/src/config.ts +148 -0
- package/src/database.ts +1530 -0
- package/src/db.test.ts +60 -0
- package/src/db.ts +190 -0
- package/src/discord-api.ts +35 -0
- package/src/discord-bot.ts +1316 -0
- package/src/discord-utils.test.ts +132 -0
- package/src/discord-utils.ts +767 -0
- package/src/errors.ts +213 -0
- package/src/escape-backticks.test.ts +469 -0
- package/src/format-tables.test.ts +223 -0
- package/src/format-tables.ts +145 -0
- package/src/forum-sync/config.ts +92 -0
- package/src/forum-sync/discord-operations.ts +241 -0
- package/src/forum-sync/index.ts +9 -0
- package/src/forum-sync/markdown.ts +176 -0
- package/src/forum-sync/sync-to-discord.ts +595 -0
- package/src/forum-sync/sync-to-files.ts +294 -0
- package/src/forum-sync/types.ts +175 -0
- package/src/forum-sync/watchers.ts +454 -0
- package/src/genai-worker-wrapper.ts +164 -0
- package/src/genai-worker.ts +386 -0
- package/src/genai.ts +321 -0
- package/src/generated/browser.ts +109 -0
- package/src/generated/client.ts +131 -0
- package/src/generated/commonInputTypes.ts +512 -0
- package/src/generated/enums.ts +46 -0
- package/src/generated/internal/class.ts +362 -0
- package/src/generated/internal/prismaNamespace.ts +2251 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +308 -0
- package/src/generated/models/bot_api_keys.ts +1288 -0
- package/src/generated/models/bot_tokens.ts +1577 -0
- package/src/generated/models/channel_agents.ts +1256 -0
- package/src/generated/models/channel_directories.ts +2104 -0
- package/src/generated/models/channel_mention_mode.ts +1300 -0
- package/src/generated/models/channel_models.ts +1288 -0
- package/src/generated/models/channel_verbosity.ts +1224 -0
- package/src/generated/models/channel_worktrees.ts +1308 -0
- package/src/generated/models/forum_sync_configs.ts +1452 -0
- package/src/generated/models/global_models.ts +1288 -0
- package/src/generated/models/ipc_requests.ts +1485 -0
- package/src/generated/models/part_messages.ts +1302 -0
- package/src/generated/models/scheduled_tasks.ts +2320 -0
- package/src/generated/models/session_agents.ts +1086 -0
- package/src/generated/models/session_models.ts +1114 -0
- package/src/generated/models/session_start_sources.ts +1408 -0
- package/src/generated/models/thread_sessions.ts +1599 -0
- package/src/generated/models/thread_worktrees.ts +1352 -0
- package/src/generated/models.ts +29 -0
- package/src/heap-monitor.ts +121 -0
- package/src/hrana-server.test.ts +428 -0
- package/src/hrana-server.ts +547 -0
- package/src/image-utils.ts +149 -0
- package/src/interaction-handler.ts +461 -0
- package/src/ipc-polling.ts +325 -0
- package/src/kimaki-digital-twin.e2e.test.ts +201 -0
- package/src/limit-heading-depth.test.ts +116 -0
- package/src/limit-heading-depth.ts +26 -0
- package/src/logger.ts +203 -0
- package/src/markdown.test.ts +360 -0
- package/src/markdown.ts +410 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +549 -0
- package/src/openai-realtime.ts +362 -0
- package/src/opencode-plugin-loading.e2e.test.ts +112 -0
- package/src/opencode-plugin.test.ts +108 -0
- package/src/opencode-plugin.ts +652 -0
- package/src/opencode.ts +554 -0
- package/src/privacy-sanitizer.ts +142 -0
- package/src/schema.sql +158 -0
- package/src/sentry.ts +137 -0
- package/src/session-handler/state.ts +232 -0
- package/src/session-handler.ts +2668 -0
- package/src/session-search.test.ts +50 -0
- package/src/session-search.ts +148 -0
- package/src/startup-service.ts +200 -0
- package/src/system-message.ts +568 -0
- package/src/task-runner.ts +425 -0
- package/src/task-schedule.test.ts +84 -0
- package/src/task-schedule.ts +287 -0
- package/src/thinking-utils.ts +61 -0
- package/src/thread-message-queue.e2e.test.ts +997 -0
- package/src/tools.ts +432 -0
- package/src/unnest-code-blocks.test.ts +679 -0
- package/src/unnest-code-blocks.ts +168 -0
- package/src/upgrade.ts +127 -0
- package/src/utils.ts +145 -0
- package/src/voice-handler.ts +852 -0
- package/src/voice.test.ts +219 -0
- package/src/voice.ts +444 -0
- package/src/wait-session.ts +147 -0
- package/src/worker-types.ts +64 -0
- package/src/worktree-utils.ts +988 -0
- package/src/xml.test.ts +38 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
// E2e tests for per-thread message queue ordering (threadMessageQueue).
|
|
2
|
+
// Validates that messages in the same thread are processed sequentially
|
|
3
|
+
// in Discord arrival order, and that immediate interrupt allows
|
|
4
|
+
// queued messages to start without waiting for the full prior response.
|
|
5
|
+
//
|
|
6
|
+
// The threadMessageQueue (Map<string, Promise<void>>) only serializes messages
|
|
7
|
+
// arriving in threads — the initial text channel message goes through a separate
|
|
8
|
+
// code path (creates thread + calls handleOpencodeSession directly). So each
|
|
9
|
+
// test first establishes a session via the initial message, waits for the bot
|
|
10
|
+
// reply, then sends follow-up messages into the thread to exercise the queue.
|
|
11
|
+
//
|
|
12
|
+
// Bot replies may be error messages (e.g. "opencode session error: Not Found")
|
|
13
|
+
// rather than actual LLM content, depending on provider/session state. The
|
|
14
|
+
// tests verify ordering by message position, not content matching.
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import url from 'node:url';
|
|
18
|
+
import { describe, beforeAll, afterAll, test, expect } from 'vitest';
|
|
19
|
+
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
20
|
+
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
21
|
+
import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
|
|
22
|
+
import { getDefaultVerbosity, setDataDir, setDefaultVerbosity, } from './config.js';
|
|
23
|
+
import { startDiscordBot } from './discord-bot.js';
|
|
24
|
+
import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, getChannelVerbosity, } from './database.js';
|
|
25
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
26
|
+
import { getOpencodeServers } from './opencode.js';
|
|
27
|
+
const e2eTest = describe;
|
|
28
|
+
function createRunDirectories() {
|
|
29
|
+
const root = path.resolve(process.cwd(), 'tmp', 'thread-queue-e2e');
|
|
30
|
+
fs.mkdirSync(root, { recursive: true });
|
|
31
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
32
|
+
const projectDirectory = path.join(root, 'project');
|
|
33
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
34
|
+
return { root, dataDir, projectDirectory };
|
|
35
|
+
}
|
|
36
|
+
function chooseLockPort() {
|
|
37
|
+
return 47_000 + (Date.now() % 2_000);
|
|
38
|
+
}
|
|
39
|
+
function createDiscordJsClient({ restUrl }) {
|
|
40
|
+
return new Client({
|
|
41
|
+
intents: [
|
|
42
|
+
GatewayIntentBits.Guilds,
|
|
43
|
+
GatewayIntentBits.GuildMessages,
|
|
44
|
+
GatewayIntentBits.MessageContent,
|
|
45
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
46
|
+
],
|
|
47
|
+
partials: [
|
|
48
|
+
Partials.Channel,
|
|
49
|
+
Partials.Message,
|
|
50
|
+
Partials.User,
|
|
51
|
+
Partials.ThreadMember,
|
|
52
|
+
],
|
|
53
|
+
rest: {
|
|
54
|
+
api: restUrl,
|
|
55
|
+
version: '10',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function cleanupOpencodeServers() {
|
|
60
|
+
const servers = getOpencodeServers();
|
|
61
|
+
for (const [, server] of servers) {
|
|
62
|
+
if (!server.process.killed) {
|
|
63
|
+
server.process.kill('SIGTERM');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
servers.clear();
|
|
67
|
+
}
|
|
68
|
+
function createDeterministicMatchers() {
|
|
69
|
+
const raceFinalReplyMatcher = {
|
|
70
|
+
id: 'race-final-reply',
|
|
71
|
+
priority: 110,
|
|
72
|
+
when: {
|
|
73
|
+
rawPromptIncludes: 'Reply with exactly: race-final',
|
|
74
|
+
},
|
|
75
|
+
then: {
|
|
76
|
+
parts: [
|
|
77
|
+
{ type: 'stream-start', warnings: [] },
|
|
78
|
+
{ type: 'text-start', id: 'race-final' },
|
|
79
|
+
{ type: 'text-delta', id: 'race-final', delta: 'race-final' },
|
|
80
|
+
{ type: 'text-end', id: 'race-final' },
|
|
81
|
+
{
|
|
82
|
+
type: 'finish',
|
|
83
|
+
finishReason: 'stop',
|
|
84
|
+
usage: {
|
|
85
|
+
inputTokens: 1,
|
|
86
|
+
outputTokens: 1,
|
|
87
|
+
totalTokens: 2,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
// Delay first output to widen the window where a stale idle could end
|
|
92
|
+
// this new request before it emits any assistant text.
|
|
93
|
+
partDelaysMs: [0, 2500, 0, 0, 0],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const sleepMatcher = {
|
|
97
|
+
id: 'sleep-tool-call',
|
|
98
|
+
priority: 100,
|
|
99
|
+
when: {
|
|
100
|
+
rawPromptIncludes: 'MANDATORY INSTRUCTION: call the bash tool immediately and run exactly this command: `sleep 500`',
|
|
101
|
+
},
|
|
102
|
+
then: {
|
|
103
|
+
parts: [
|
|
104
|
+
{ type: 'stream-start', warnings: [] },
|
|
105
|
+
{ type: 'text-start', id: 'sleep-start' },
|
|
106
|
+
{ type: 'text-delta', id: 'sleep-start', delta: 'running sleep 500' },
|
|
107
|
+
{ type: 'text-end', id: 'sleep-start' },
|
|
108
|
+
{
|
|
109
|
+
type: 'tool-call',
|
|
110
|
+
toolCallId: 'sleep-call-1',
|
|
111
|
+
toolName: 'bash',
|
|
112
|
+
input: JSON.stringify({
|
|
113
|
+
command: 'sleep 500',
|
|
114
|
+
description: 'Deterministic sleep for interrupt e2e',
|
|
115
|
+
hasSideEffect: true,
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: 'finish',
|
|
120
|
+
finishReason: 'tool-calls',
|
|
121
|
+
usage: {
|
|
122
|
+
inputTokens: 1,
|
|
123
|
+
outputTokens: 1,
|
|
124
|
+
totalTokens: 2,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const toolFollowupMatcher = {
|
|
131
|
+
id: 'tool-followup',
|
|
132
|
+
priority: 50,
|
|
133
|
+
when: {
|
|
134
|
+
lastMessageRole: 'tool',
|
|
135
|
+
},
|
|
136
|
+
then: {
|
|
137
|
+
parts: [
|
|
138
|
+
{ type: 'stream-start', warnings: [] },
|
|
139
|
+
{ type: 'text-start', id: 'tool-followup' },
|
|
140
|
+
{ type: 'text-delta', id: 'tool-followup', delta: 'tool done' },
|
|
141
|
+
{ type: 'text-end', id: 'tool-followup' },
|
|
142
|
+
{
|
|
143
|
+
type: 'finish',
|
|
144
|
+
finishReason: 'stop',
|
|
145
|
+
usage: {
|
|
146
|
+
inputTokens: 1,
|
|
147
|
+
outputTokens: 1,
|
|
148
|
+
totalTokens: 2,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const userReplyMatcher = {
|
|
155
|
+
id: 'user-reply',
|
|
156
|
+
priority: 10,
|
|
157
|
+
when: {
|
|
158
|
+
lastMessageRole: 'user',
|
|
159
|
+
rawPromptIncludes: 'Reply with exactly:',
|
|
160
|
+
},
|
|
161
|
+
then: {
|
|
162
|
+
parts: [
|
|
163
|
+
{ type: 'stream-start', warnings: [] },
|
|
164
|
+
{ type: 'text-start', id: 'default-reply' },
|
|
165
|
+
{ type: 'text-delta', id: 'default-reply', delta: 'ok' },
|
|
166
|
+
{ type: 'text-end', id: 'default-reply' },
|
|
167
|
+
{
|
|
168
|
+
type: 'finish',
|
|
169
|
+
finishReason: 'stop',
|
|
170
|
+
usage: {
|
|
171
|
+
inputTokens: 1,
|
|
172
|
+
outputTokens: 1,
|
|
173
|
+
totalTokens: 2,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
partDelaysMs: [0, 700, 0, 0, 0],
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
return [
|
|
181
|
+
sleepMatcher,
|
|
182
|
+
raceFinalReplyMatcher,
|
|
183
|
+
toolFollowupMatcher,
|
|
184
|
+
userReplyMatcher,
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
/** Poll getMessages until we see at least `count` bot messages. */
|
|
188
|
+
async function waitForBotMessageCount({ discord, threadId, count, timeout, }) {
|
|
189
|
+
const start = Date.now();
|
|
190
|
+
while (Date.now() - start < timeout) {
|
|
191
|
+
const messages = await discord.thread(threadId).getMessages();
|
|
192
|
+
const botMessages = messages.filter((m) => {
|
|
193
|
+
return m.author.id === discord.botUserId;
|
|
194
|
+
});
|
|
195
|
+
if (botMessages.length >= count) {
|
|
196
|
+
return messages;
|
|
197
|
+
}
|
|
198
|
+
await new Promise((r) => {
|
|
199
|
+
setTimeout(r, 500);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`Timed out waiting for ${count} bot messages in thread ${threadId}`);
|
|
203
|
+
}
|
|
204
|
+
async function waitForBotReplyAfterUserMessage({ discord, threadId, userMessageIncludes, timeout, }) {
|
|
205
|
+
const start = Date.now();
|
|
206
|
+
while (Date.now() - start < timeout) {
|
|
207
|
+
const messages = await discord.thread(threadId).getMessages();
|
|
208
|
+
const userMessageIndex = messages.findIndex((message) => {
|
|
209
|
+
return (message.author.id === TEST_USER_ID &&
|
|
210
|
+
message.content.includes(userMessageIncludes));
|
|
211
|
+
});
|
|
212
|
+
const botReplyIndex = messages.findIndex((message, index) => {
|
|
213
|
+
return index > userMessageIndex && message.author.id === discord.botUserId;
|
|
214
|
+
});
|
|
215
|
+
if (userMessageIndex >= 0 && botReplyIndex >= 0) {
|
|
216
|
+
return messages;
|
|
217
|
+
}
|
|
218
|
+
await new Promise((resolve) => {
|
|
219
|
+
setTimeout(resolve, 500);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`Timed out waiting for bot reply after user message containing "${userMessageIncludes}" in thread ${threadId}`);
|
|
223
|
+
}
|
|
224
|
+
async function waitForBotMessageContaining({ discord, threadId, text, afterUserMessageIncludes, timeout, }) {
|
|
225
|
+
const start = Date.now();
|
|
226
|
+
let lastMessages = [];
|
|
227
|
+
while (Date.now() - start < timeout) {
|
|
228
|
+
const messages = await discord.thread(threadId).getMessages();
|
|
229
|
+
lastMessages = messages;
|
|
230
|
+
const afterIndex = afterUserMessageIncludes
|
|
231
|
+
? messages.findIndex((message) => {
|
|
232
|
+
return (message.author.id === TEST_USER_ID &&
|
|
233
|
+
message.content.includes(afterUserMessageIncludes));
|
|
234
|
+
})
|
|
235
|
+
: -1;
|
|
236
|
+
const match = messages.find((message, index) => {
|
|
237
|
+
if (afterUserMessageIncludes && afterIndex >= 0 && index <= afterIndex) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return (message.author.id === discord.botUserId &&
|
|
241
|
+
message.content.includes(text));
|
|
242
|
+
});
|
|
243
|
+
if (match) {
|
|
244
|
+
return messages;
|
|
245
|
+
}
|
|
246
|
+
await new Promise((resolve) => {
|
|
247
|
+
setTimeout(resolve, 500);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const recent = lastMessages
|
|
251
|
+
.slice(-12)
|
|
252
|
+
.map((message) => {
|
|
253
|
+
const role = message.author.id === discord.botUserId ? 'bot' : 'user';
|
|
254
|
+
return `${role}: ${message.content.slice(0, 120)}`;
|
|
255
|
+
})
|
|
256
|
+
.join('\n');
|
|
257
|
+
throw new Error(`Timed out waiting for bot message containing "${text}" in thread ${threadId}. Recent messages:\n${recent}`);
|
|
258
|
+
}
|
|
259
|
+
const TEST_USER_ID = '200000000000000777';
|
|
260
|
+
const TEXT_CHANNEL_ID = '200000000000000778';
|
|
261
|
+
e2eTest('thread message queue ordering', () => {
|
|
262
|
+
let directories;
|
|
263
|
+
let discord;
|
|
264
|
+
let botClient;
|
|
265
|
+
let previousDefaultVerbosity = null;
|
|
266
|
+
beforeAll(async () => {
|
|
267
|
+
directories = createRunDirectories();
|
|
268
|
+
const lockPort = chooseLockPort();
|
|
269
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
|
|
270
|
+
setDataDir(directories.dataDir);
|
|
271
|
+
previousDefaultVerbosity = getDefaultVerbosity();
|
|
272
|
+
setDefaultVerbosity('tools-and-text');
|
|
273
|
+
discord = new DigitalDiscord({
|
|
274
|
+
guild: {
|
|
275
|
+
name: 'Queue E2E Guild',
|
|
276
|
+
ownerId: TEST_USER_ID,
|
|
277
|
+
},
|
|
278
|
+
channels: [
|
|
279
|
+
{
|
|
280
|
+
id: TEXT_CHANNEL_ID,
|
|
281
|
+
name: 'queue-e2e',
|
|
282
|
+
type: ChannelType.GuildText,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
users: [
|
|
286
|
+
{
|
|
287
|
+
id: TEST_USER_ID,
|
|
288
|
+
username: 'queue-tester',
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
await discord.start();
|
|
293
|
+
const providerNpm = url
|
|
294
|
+
.pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
|
|
295
|
+
.toString();
|
|
296
|
+
const opencodeConfig = buildDeterministicOpencodeConfig({
|
|
297
|
+
providerName: 'deterministic-provider',
|
|
298
|
+
providerNpm,
|
|
299
|
+
model: 'deterministic-v2',
|
|
300
|
+
smallModel: 'deterministic-v2',
|
|
301
|
+
settings: {
|
|
302
|
+
strict: false,
|
|
303
|
+
matchers: createDeterministicMatchers(),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
307
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
|
|
308
|
+
const hranaResult = await startHranaServer({ dbPath });
|
|
309
|
+
if (hranaResult instanceof Error) {
|
|
310
|
+
throw hranaResult;
|
|
311
|
+
}
|
|
312
|
+
process.env['KIMAKI_DB_URL'] = hranaResult;
|
|
313
|
+
await initDatabase();
|
|
314
|
+
await setBotToken(discord.botUserId, discord.botToken);
|
|
315
|
+
await setChannelDirectory({
|
|
316
|
+
channelId: TEXT_CHANNEL_ID,
|
|
317
|
+
directory: directories.projectDirectory,
|
|
318
|
+
channelType: 'text',
|
|
319
|
+
appId: discord.botUserId,
|
|
320
|
+
});
|
|
321
|
+
await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools-and-text');
|
|
322
|
+
const channelVerbosity = await getChannelVerbosity(TEXT_CHANNEL_ID);
|
|
323
|
+
expect(channelVerbosity).toBe('tools-and-text');
|
|
324
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl });
|
|
325
|
+
await startDiscordBot({
|
|
326
|
+
token: discord.botToken,
|
|
327
|
+
appId: discord.botUserId,
|
|
328
|
+
discordClient: botClient,
|
|
329
|
+
});
|
|
330
|
+
}, 60_000);
|
|
331
|
+
afterAll(async () => {
|
|
332
|
+
if (botClient) {
|
|
333
|
+
botClient.destroy();
|
|
334
|
+
}
|
|
335
|
+
await cleanupOpencodeServers();
|
|
336
|
+
await Promise.all([
|
|
337
|
+
closeDatabase().catch(() => {
|
|
338
|
+
return;
|
|
339
|
+
}),
|
|
340
|
+
stopHranaServer().catch(() => {
|
|
341
|
+
return;
|
|
342
|
+
}),
|
|
343
|
+
discord?.stop().catch(() => {
|
|
344
|
+
return;
|
|
345
|
+
}),
|
|
346
|
+
]);
|
|
347
|
+
delete process.env['KIMAKI_LOCK_PORT'];
|
|
348
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
349
|
+
if (previousDefaultVerbosity) {
|
|
350
|
+
setDefaultVerbosity(previousDefaultVerbosity);
|
|
351
|
+
}
|
|
352
|
+
if (directories) {
|
|
353
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
}, 30_000);
|
|
356
|
+
test('text message during active session gets processed', async () => {
|
|
357
|
+
// 1. Send initial message to text channel → thread created + session established
|
|
358
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
359
|
+
content: 'Reply with exactly: alpha',
|
|
360
|
+
});
|
|
361
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
362
|
+
timeout: 60_000,
|
|
363
|
+
predicate: (t) => {
|
|
364
|
+
return t.name === 'Reply with exactly: alpha';
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const th = discord.thread(thread.id);
|
|
368
|
+
// Wait for the first bot reply so session is fully established in DB
|
|
369
|
+
const firstReply = await th.waitForBotReply({
|
|
370
|
+
timeout: 120_000,
|
|
371
|
+
});
|
|
372
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
373
|
+
// Snapshot bot message count before sending follow-up
|
|
374
|
+
const before = await th.getMessages();
|
|
375
|
+
const beforeBotCount = before.filter((m) => {
|
|
376
|
+
return m.author.id === discord.botUserId;
|
|
377
|
+
}).length;
|
|
378
|
+
// 2. Send follow-up message B into the thread — goes through threadMessageQueue
|
|
379
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
380
|
+
content: 'Reply with exactly: beta',
|
|
381
|
+
});
|
|
382
|
+
// 3. Wait for exactly 1 new bot message (the response to B)
|
|
383
|
+
const after = await waitForBotMessageCount({
|
|
384
|
+
discord,
|
|
385
|
+
threadId: thread.id,
|
|
386
|
+
count: beforeBotCount + 1,
|
|
387
|
+
timeout: 120_000,
|
|
388
|
+
});
|
|
389
|
+
// 4. Verify at least 1 new bot message appeared for the follow-up.
|
|
390
|
+
// The bot may send additional messages per session (error reactions,
|
|
391
|
+
// session notifications) so we check >= not exact equality.
|
|
392
|
+
const afterBotMessages = after.filter((m) => {
|
|
393
|
+
return m.author.id === discord.botUserId;
|
|
394
|
+
});
|
|
395
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1);
|
|
396
|
+
// User B's message must appear before the new bot response
|
|
397
|
+
const userBIndex = after.findIndex((m) => {
|
|
398
|
+
return (m.author.id === TEST_USER_ID &&
|
|
399
|
+
m.content.includes('beta'));
|
|
400
|
+
});
|
|
401
|
+
const lastBotIndex = after.findLastIndex((m) => {
|
|
402
|
+
return m.author.id === discord.botUserId;
|
|
403
|
+
});
|
|
404
|
+
expect(userBIndex).toBeGreaterThan(-1);
|
|
405
|
+
expect(lastBotIndex).toBeGreaterThan(-1);
|
|
406
|
+
expect(userBIndex).toBeLessThan(lastBotIndex);
|
|
407
|
+
// New bot response has non-empty content
|
|
408
|
+
const newBotReply = afterBotMessages[afterBotMessages.length - 1];
|
|
409
|
+
expect(newBotReply.content.trim().length).toBeGreaterThan(0);
|
|
410
|
+
}, 360_000);
|
|
411
|
+
test('two rapid text messages in thread — both processed in order', async () => {
|
|
412
|
+
// 1. Send initial message to text channel → thread + session established
|
|
413
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
414
|
+
content: 'Reply with exactly: one',
|
|
415
|
+
});
|
|
416
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
417
|
+
timeout: 60_000,
|
|
418
|
+
predicate: (t) => {
|
|
419
|
+
return t.name === 'Reply with exactly: one';
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
const th = discord.thread(thread.id);
|
|
423
|
+
// Wait for the first bot reply so session is established
|
|
424
|
+
const firstReply = await th.waitForBotReply({
|
|
425
|
+
timeout: 120_000,
|
|
426
|
+
});
|
|
427
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
428
|
+
// Snapshot bot message count before sending follow-ups
|
|
429
|
+
const before = await th.getMessages();
|
|
430
|
+
const beforeBotCount = before.filter((m) => {
|
|
431
|
+
return m.author.id === discord.botUserId;
|
|
432
|
+
}).length;
|
|
433
|
+
// 2. Rapidly send messages B and C — both go through threadMessageQueue
|
|
434
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
435
|
+
content: 'Reply with exactly: two',
|
|
436
|
+
});
|
|
437
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
438
|
+
content: 'Reply with exactly: three',
|
|
439
|
+
});
|
|
440
|
+
// 3. Wait for exactly 2 new bot messages (one per follow-up)
|
|
441
|
+
const after = await waitForBotMessageCount({
|
|
442
|
+
discord,
|
|
443
|
+
threadId: thread.id,
|
|
444
|
+
count: beforeBotCount + 2,
|
|
445
|
+
timeout: 120_000,
|
|
446
|
+
});
|
|
447
|
+
// 4. Verify at least 2 new bot messages appeared (one per follow-up).
|
|
448
|
+
// The bot may send additional messages per session (error reactions,
|
|
449
|
+
// session notifications) so we check >= not exact equality.
|
|
450
|
+
const afterBotMessages = after.filter((m) => {
|
|
451
|
+
return m.author.id === discord.botUserId;
|
|
452
|
+
});
|
|
453
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 2);
|
|
454
|
+
// Each new bot message has non-empty content
|
|
455
|
+
const newBotReplies = afterBotMessages.slice(beforeBotCount);
|
|
456
|
+
for (const reply of newBotReplies) {
|
|
457
|
+
expect(reply.content.trim().length).toBeGreaterThan(0);
|
|
458
|
+
}
|
|
459
|
+
// 5. Verify per-follow-up causality: user B appears before 2nd bot
|
|
460
|
+
// message, user C appears before 3rd bot message
|
|
461
|
+
const botIndices = after.reduce((acc, m, i) => {
|
|
462
|
+
if (m.author.id === discord.botUserId) {
|
|
463
|
+
acc.push(i);
|
|
464
|
+
}
|
|
465
|
+
return acc;
|
|
466
|
+
}, []);
|
|
467
|
+
const userTwoIndex = after.findIndex((m) => {
|
|
468
|
+
return (m.author.id === TEST_USER_ID &&
|
|
469
|
+
m.content.includes('two'));
|
|
470
|
+
});
|
|
471
|
+
const userThreeIndex = after.findIndex((m) => {
|
|
472
|
+
return (m.author.id === TEST_USER_ID &&
|
|
473
|
+
m.content.includes('three'));
|
|
474
|
+
});
|
|
475
|
+
expect(userTwoIndex).toBeGreaterThan(-1);
|
|
476
|
+
expect(userThreeIndex).toBeGreaterThan(-1);
|
|
477
|
+
// Bot responses for B and C are the last 2 bot messages
|
|
478
|
+
const botForB = botIndices[botIndices.length - 2];
|
|
479
|
+
const botForC = botIndices[botIndices.length - 1];
|
|
480
|
+
// Each user message appears before its corresponding bot response
|
|
481
|
+
expect(userTwoIndex).toBeLessThan(botForB);
|
|
482
|
+
expect(userThreeIndex).toBeLessThan(botForC);
|
|
483
|
+
// Bot response for B appears before bot response for C (queue order)
|
|
484
|
+
expect(botForB).toBeLessThan(botForC);
|
|
485
|
+
}, 360_000);
|
|
486
|
+
test('queued message aborts running session immediately', async () => {
|
|
487
|
+
// When a new message queues behind a running session,
|
|
488
|
+
// signalThreadInterrupt aborts the in-flight session immediately,
|
|
489
|
+
// then the queue processes the next message.
|
|
490
|
+
//
|
|
491
|
+
// 1. Fast setup: establish session
|
|
492
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
493
|
+
content: 'Reply with exactly: delta',
|
|
494
|
+
});
|
|
495
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
496
|
+
timeout: 60_000,
|
|
497
|
+
predicate: (t) => {
|
|
498
|
+
return t.name === 'Reply with exactly: delta';
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
const th = discord.thread(thread.id);
|
|
502
|
+
const firstReply = await th.waitForBotReply({ timeout: 120_000 });
|
|
503
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
504
|
+
const before = await th.getMessages();
|
|
505
|
+
const beforeBotCount = before.filter((m) => {
|
|
506
|
+
return m.author.id === discord.botUserId;
|
|
507
|
+
}).length;
|
|
508
|
+
// 2. Send B, then quickly send C to trigger the interrupt.
|
|
509
|
+
// 200ms gap gives B time to enter the queue and start processing.
|
|
510
|
+
// signalThreadInterrupt aborts B immediately so C can run.
|
|
511
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
512
|
+
content: 'Reply with exactly: echo',
|
|
513
|
+
});
|
|
514
|
+
await new Promise((r) => {
|
|
515
|
+
setTimeout(r, 200);
|
|
516
|
+
});
|
|
517
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
518
|
+
content: 'Reply with exactly: foxtrot',
|
|
519
|
+
});
|
|
520
|
+
// 3. Poll until foxtrot's user message has a bot reply after it.
|
|
521
|
+
// waitForBotMessageCount alone isn't enough — error messages from the
|
|
522
|
+
// interrupted session can satisfy the count before foxtrot gets its reply.
|
|
523
|
+
const after = await waitForBotReplyAfterUserMessage({
|
|
524
|
+
discord,
|
|
525
|
+
threadId: thread.id,
|
|
526
|
+
userMessageIncludes: 'foxtrot',
|
|
527
|
+
timeout: 120_000,
|
|
528
|
+
});
|
|
529
|
+
// 4. Both B and C got bot responses
|
|
530
|
+
const afterBotMessages = after.filter((m) => {
|
|
531
|
+
return m.author.id === discord.botUserId;
|
|
532
|
+
});
|
|
533
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 2);
|
|
534
|
+
const userEchoIndex = after.findIndex((m) => {
|
|
535
|
+
return m.author.id === TEST_USER_ID && m.content.includes('echo');
|
|
536
|
+
});
|
|
537
|
+
const userFoxtrotIndex = after.findIndex((m) => {
|
|
538
|
+
return m.author.id === TEST_USER_ID && m.content.includes('foxtrot');
|
|
539
|
+
});
|
|
540
|
+
expect(userEchoIndex).toBeGreaterThan(-1);
|
|
541
|
+
expect(userFoxtrotIndex).toBeGreaterThan(-1);
|
|
542
|
+
// Foxtrot's bot reply appears after the foxtrot user message
|
|
543
|
+
const botAfterFoxtrot = after.findIndex((m, i) => {
|
|
544
|
+
return i > userFoxtrotIndex && m.author.id === discord.botUserId;
|
|
545
|
+
});
|
|
546
|
+
expect(botAfterFoxtrot).toBeGreaterThan(userFoxtrotIndex);
|
|
547
|
+
}, 360_000);
|
|
548
|
+
test('slow stream still gets interrupted when no step-finish arrives', async () => {
|
|
549
|
+
// With immediate abort, a queued message interrupts even while the previous
|
|
550
|
+
// request is mid-stream and has not reached a step-finish event.
|
|
551
|
+
// 1. Fast setup: establish session
|
|
552
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
553
|
+
content: 'Reply with exactly: golf',
|
|
554
|
+
});
|
|
555
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
556
|
+
timeout: 60_000,
|
|
557
|
+
predicate: (t) => {
|
|
558
|
+
return t.name === 'Reply with exactly: golf';
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
const th = discord.thread(thread.id);
|
|
562
|
+
const firstReply = await th.waitForBotReply({ timeout: 120_000 });
|
|
563
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
564
|
+
const before = await th.getMessages();
|
|
565
|
+
const beforeBotCount = before.filter((m) => {
|
|
566
|
+
return m.author.id === discord.botUserId;
|
|
567
|
+
}).length;
|
|
568
|
+
// 2. Start request B, then send C while B is still in progress.
|
|
569
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
570
|
+
content: 'Reply with exactly: hotel',
|
|
571
|
+
});
|
|
572
|
+
// 3. Wait briefly for B to start, then send C to trigger immediate abort
|
|
573
|
+
await new Promise((r) => {
|
|
574
|
+
setTimeout(r, 500);
|
|
575
|
+
});
|
|
576
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
577
|
+
content: 'Reply with exactly: india',
|
|
578
|
+
});
|
|
579
|
+
// 4. B is aborted and C gets processed.
|
|
580
|
+
// Poll until india's user message has a bot reply after it.
|
|
581
|
+
const after = await waitForBotReplyAfterUserMessage({
|
|
582
|
+
discord,
|
|
583
|
+
threadId: thread.id,
|
|
584
|
+
userMessageIncludes: 'india',
|
|
585
|
+
timeout: 120_000,
|
|
586
|
+
});
|
|
587
|
+
// C's user message appears before its bot response.
|
|
588
|
+
// The interrupted hotel session may or may not produce a visible bot message
|
|
589
|
+
// (depends on timing), so we only assert on india's reply existence.
|
|
590
|
+
const userIndiaIndex = after.findIndex((m) => {
|
|
591
|
+
return m.author.id === TEST_USER_ID && m.content.includes('india');
|
|
592
|
+
});
|
|
593
|
+
expect(userIndiaIndex).toBeGreaterThan(-1);
|
|
594
|
+
const botAfterIndia = after.findIndex((m, i) => {
|
|
595
|
+
return i > userIndiaIndex && m.author.id === discord.botUserId;
|
|
596
|
+
});
|
|
597
|
+
expect(botAfterIndia).toBeGreaterThan(userIndiaIndex);
|
|
598
|
+
}, 360_000);
|
|
599
|
+
test('queue drains correctly after interrupted session', async () => {
|
|
600
|
+
// Verifies the queue doesn't get stuck after multiple interrupts.
|
|
601
|
+
// Rapidly sends B, C, D — each interrupts the previous. Then after all
|
|
602
|
+
// complete, sends E to prove the queue is clean and accepting new work.
|
|
603
|
+
// 1. Fast setup: establish session
|
|
604
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
605
|
+
content: 'Reply with exactly: juliet',
|
|
606
|
+
});
|
|
607
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
608
|
+
timeout: 60_000,
|
|
609
|
+
predicate: (t) => {
|
|
610
|
+
return t.name === 'Reply with exactly: juliet';
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
const th = discord.thread(thread.id);
|
|
614
|
+
const firstReply = await th.waitForBotReply({ timeout: 120_000 });
|
|
615
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
616
|
+
const before = await th.getMessages();
|
|
617
|
+
const beforeBotCount = before.filter((m) => {
|
|
618
|
+
return m.author.id === discord.botUserId;
|
|
619
|
+
}).length;
|
|
620
|
+
// 2. Rapidly send B, C, D — each queues behind the previous and triggers interrupt
|
|
621
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
622
|
+
content: 'Reply with exactly: kilo',
|
|
623
|
+
});
|
|
624
|
+
await new Promise((r) => {
|
|
625
|
+
setTimeout(r, 300);
|
|
626
|
+
});
|
|
627
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
628
|
+
content: 'Reply with exactly: lima',
|
|
629
|
+
});
|
|
630
|
+
await new Promise((r) => {
|
|
631
|
+
setTimeout(r, 300);
|
|
632
|
+
});
|
|
633
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
634
|
+
content: 'Reply with exactly: mike',
|
|
635
|
+
});
|
|
636
|
+
// 3. Wait until the last burst message (mike) has a bot reply after it.
|
|
637
|
+
const afterBurst = await waitForBotReplyAfterUserMessage({
|
|
638
|
+
discord,
|
|
639
|
+
threadId: thread.id,
|
|
640
|
+
userMessageIncludes: 'mike',
|
|
641
|
+
timeout: 120_000,
|
|
642
|
+
});
|
|
643
|
+
const burstBotMessages = afterBurst.filter((m) => {
|
|
644
|
+
return m.author.id === discord.botUserId;
|
|
645
|
+
});
|
|
646
|
+
expect(burstBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1);
|
|
647
|
+
// 4. Queue should be clean — send E and verify it also gets processed
|
|
648
|
+
const burstBotCount = burstBotMessages.length;
|
|
649
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
650
|
+
content: 'Reply with exactly: november',
|
|
651
|
+
});
|
|
652
|
+
const afterE = await waitForBotReplyAfterUserMessage({
|
|
653
|
+
discord,
|
|
654
|
+
threadId: thread.id,
|
|
655
|
+
userMessageIncludes: 'november',
|
|
656
|
+
timeout: 120_000,
|
|
657
|
+
});
|
|
658
|
+
const finalBotMessages = afterE.filter((m) => {
|
|
659
|
+
return m.author.id === discord.botUserId;
|
|
660
|
+
});
|
|
661
|
+
expect(finalBotMessages.length).toBeGreaterThanOrEqual(burstBotCount);
|
|
662
|
+
// E's user message appears before the final bot response
|
|
663
|
+
const userNovemberIndex = afterE.findIndex((m) => {
|
|
664
|
+
return m.author.id === TEST_USER_ID && m.content.includes('november');
|
|
665
|
+
});
|
|
666
|
+
expect(userNovemberIndex).toBeGreaterThan(-1);
|
|
667
|
+
const lastBotIndex = afterE.findLastIndex((m) => {
|
|
668
|
+
return m.author.id === discord.botUserId;
|
|
669
|
+
});
|
|
670
|
+
expect(userNovemberIndex).toBeLessThan(lastBotIndex);
|
|
671
|
+
}, 360_000);
|
|
672
|
+
test('slow tool call (sleep) gets aborted when new message queues', async () => {
|
|
673
|
+
// Tests that long-running tool calls get properly aborted when a new
|
|
674
|
+
// message queues behind them. During tool execution no step-finish events
|
|
675
|
+
// arrive, but interrupt should still abort immediately so the queue can
|
|
676
|
+
// process the next message normally.
|
|
677
|
+
// 1. Fast setup: establish session
|
|
678
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
679
|
+
content: 'Reply with exactly: oscar',
|
|
680
|
+
});
|
|
681
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
682
|
+
timeout: 60_000,
|
|
683
|
+
predicate: (t) => {
|
|
684
|
+
return t.name === 'Reply with exactly: oscar';
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
const th = discord.thread(thread.id);
|
|
688
|
+
const firstReply = await th.waitForBotReply({ timeout: 120_000 });
|
|
689
|
+
expect(firstReply.content.trim().length).toBeGreaterThan(0);
|
|
690
|
+
const before = await th.getMessages();
|
|
691
|
+
const beforeBotCount = before.filter((m) => {
|
|
692
|
+
return m.author.id === discord.botUserId;
|
|
693
|
+
}).length;
|
|
694
|
+
// 2. Ask the model to run a long sleep command
|
|
695
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
696
|
+
content: 'MANDATORY INSTRUCTION: call the bash tool immediately and run exactly this command: `sleep 500`. No explanation. No normal text. Do not skip the tool call.',
|
|
697
|
+
});
|
|
698
|
+
// 3. Wait until we see the bash tool message for sleep, proving the tool
|
|
699
|
+
// call actually started before the interrupt message is sent.
|
|
700
|
+
await waitForBotMessageContaining({
|
|
701
|
+
discord,
|
|
702
|
+
threadId: thread.id,
|
|
703
|
+
text: 'sleep 500',
|
|
704
|
+
afterUserMessageIncludes: 'sleep 500',
|
|
705
|
+
timeout: 30_000,
|
|
706
|
+
});
|
|
707
|
+
// 4. Send interrupt message while sleep is still running
|
|
708
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
709
|
+
content: 'Reply with exactly: papa',
|
|
710
|
+
});
|
|
711
|
+
// 5. The interrupt aborts the sleep session, and the queue processes "papa".
|
|
712
|
+
const after = await waitForBotReplyAfterUserMessage({
|
|
713
|
+
discord,
|
|
714
|
+
threadId: thread.id,
|
|
715
|
+
userMessageIncludes: 'papa',
|
|
716
|
+
timeout: 120_000,
|
|
717
|
+
});
|
|
718
|
+
const afterBotMessages = after.filter((m) => {
|
|
719
|
+
return m.author.id === discord.botUserId;
|
|
720
|
+
});
|
|
721
|
+
expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1);
|
|
722
|
+
// Ensure sleep tool output appeared before the interrupt message.
|
|
723
|
+
const sleepToolIndex = after.findIndex((m) => {
|
|
724
|
+
return m.author.id === discord.botUserId && m.content.includes('sleep 500');
|
|
725
|
+
});
|
|
726
|
+
expect(sleepToolIndex).toBeGreaterThan(-1);
|
|
727
|
+
// "papa" user message appears before the last bot response
|
|
728
|
+
const userPapaIndex = after.findIndex((m) => {
|
|
729
|
+
return m.author.id === TEST_USER_ID && m.content.includes('papa');
|
|
730
|
+
});
|
|
731
|
+
expect(userPapaIndex).toBeGreaterThan(-1);
|
|
732
|
+
expect(sleepToolIndex).toBeLessThan(userPapaIndex);
|
|
733
|
+
const lastBotIndex = after.findLastIndex((m) => {
|
|
734
|
+
return m.author.id === discord.botUserId;
|
|
735
|
+
});
|
|
736
|
+
expect(userPapaIndex).toBeLessThan(lastBotIndex);
|
|
737
|
+
}, 360_000);
|
|
738
|
+
async function runInterruptRaceScenario(runIndex) {
|
|
739
|
+
// Reproduces the stale-idle timing window reported in production:
|
|
740
|
+
// 1) an active stream is interrupted by a new message,
|
|
741
|
+
// 2) late events from the interrupted stream arrive,
|
|
742
|
+
// 3) the new prompt must still produce assistant text.
|
|
743
|
+
const setupPrompt = `Reply with exactly: race-setup-${runIndex}`;
|
|
744
|
+
const raceFinalPrompt = `Reply with exactly: race-final-${runIndex}`;
|
|
745
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
746
|
+
content: setupPrompt,
|
|
747
|
+
});
|
|
748
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
749
|
+
timeout: 60_000,
|
|
750
|
+
predicate: (t) => {
|
|
751
|
+
return t.name === setupPrompt;
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
const th = discord.thread(thread.id);
|
|
755
|
+
const setupReply = await th.waitForBotReply({ timeout: 120_000 });
|
|
756
|
+
expect(setupReply.content.trim().length).toBeGreaterThan(0);
|
|
757
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
758
|
+
content: 'MANDATORY INSTRUCTION: call the bash tool immediately and run exactly this command: `sleep 500`. No explanation. No normal text. Do not skip the tool call.',
|
|
759
|
+
});
|
|
760
|
+
await waitForBotMessageContaining({
|
|
761
|
+
discord,
|
|
762
|
+
threadId: thread.id,
|
|
763
|
+
text: 'sleep 500',
|
|
764
|
+
afterUserMessageIncludes: 'sleep 500',
|
|
765
|
+
timeout: 30_000,
|
|
766
|
+
});
|
|
767
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
768
|
+
content: raceFinalPrompt,
|
|
769
|
+
});
|
|
770
|
+
await waitForBotMessageContaining({
|
|
771
|
+
discord,
|
|
772
|
+
threadId: thread.id,
|
|
773
|
+
text: 'race-final',
|
|
774
|
+
afterUserMessageIncludes: raceFinalPrompt,
|
|
775
|
+
timeout: 30_000,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
test('interrupt race: queued message still gets assistant text after stale idle window', async () => {
|
|
779
|
+
await runInterruptRaceScenario(1);
|
|
780
|
+
}, 360_000);
|
|
781
|
+
});
|