kimaki 0.4.72 → 0.4.74

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 (218) hide show
  1. package/dist/agent-model.e2e.test.js +694 -0
  2. package/dist/channel-management.js +1 -4
  3. package/dist/cli-parsing.test.js +25 -0
  4. package/dist/cli.js +755 -181
  5. package/dist/commands/abort.js +26 -32
  6. package/dist/commands/action-buttons.js +10 -22
  7. package/dist/commands/add-project.js +2 -3
  8. package/dist/commands/agent.js +63 -23
  9. package/dist/commands/create-new-project.js +12 -6
  10. package/dist/commands/diff.js +1 -1
  11. package/dist/commands/discord-install-url.js +36 -0
  12. package/dist/commands/login.js +51 -18
  13. package/dist/commands/mention-mode.js +1 -8
  14. package/dist/commands/merge-worktree.js +13 -26
  15. package/dist/commands/model.js +10 -22
  16. package/dist/commands/queue.js +61 -72
  17. package/dist/commands/restart-opencode-server.js +12 -56
  18. package/dist/commands/resume.js +2 -11
  19. package/dist/commands/run-command.js +1 -1
  20. package/dist/commands/session.js +14 -19
  21. package/dist/commands/stop-opencode-server.js +79 -0
  22. package/dist/commands/unset-model.js +7 -21
  23. package/dist/commands/user-command.js +27 -21
  24. package/dist/commands/verbosity.js +102 -37
  25. package/dist/commands/worktree-settings.js +1 -8
  26. package/dist/commands/worktree.js +9 -29
  27. package/dist/commands/worktrees.js +129 -0
  28. package/dist/config.js +16 -47
  29. package/dist/database.js +152 -43
  30. package/dist/db.js +48 -0
  31. package/dist/db.test.js +90 -1
  32. package/dist/debounced-process-flush.js +77 -0
  33. package/dist/discord-bot.js +206 -295
  34. package/dist/discord-urls.js +70 -0
  35. package/dist/discord-utils.js +2 -3
  36. package/dist/event-stream-real-capture.e2e.test.js +549 -0
  37. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  38. package/dist/gateway-proxy.e2e.test.js +481 -0
  39. package/dist/generated/client.js +3 -1
  40. package/dist/generated/enums.js +18 -0
  41. package/dist/generated/internal/class.js +12 -4
  42. package/dist/generated/internal/prismaNamespace.js +19 -6
  43. package/dist/generated/internal/prismaNamespaceBrowser.js +15 -2
  44. package/dist/generated/models/session_events.js +1 -0
  45. package/dist/hrana-server.js +5 -4
  46. package/dist/install-url.js +27 -0
  47. package/dist/interaction-handler.js +22 -3
  48. package/dist/kimaki-digital-twin.e2e.test.js +25 -13
  49. package/dist/logger.js +10 -6
  50. package/dist/markdown.test.js +215 -210
  51. package/dist/message-preprocessing.js +186 -0
  52. package/dist/opencode-interrupt-plugin.js +229 -0
  53. package/dist/opencode-interrupt-plugin.test.js +479 -0
  54. package/dist/opencode-plugin-interrupt.test.js +138 -0
  55. package/dist/opencode-plugin-loading.e2e.test.js +2 -2
  56. package/dist/opencode-plugin.js +3 -153
  57. package/dist/opencode.js +150 -25
  58. package/dist/queue-advanced-abort.e2e.test.js +295 -0
  59. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  60. package/dist/queue-advanced-e2e-setup.js +371 -0
  61. package/dist/queue-advanced-footer.e2e.test.js +299 -0
  62. package/dist/queue-advanced-permissions-typing.e2e.test.js +104 -0
  63. package/dist/queue-advanced-typing.e2e.test.js +163 -0
  64. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  65. package/dist/runtime-idle-sweeper.js +59 -0
  66. package/dist/runtime-lifecycle.e2e.test.js +467 -0
  67. package/dist/sentry.js +3 -0
  68. package/dist/session-handler/agent-utils.js +67 -0
  69. package/dist/session-handler/event-stream-state.js +381 -0
  70. package/dist/session-handler/event-stream-state.test.js +426 -0
  71. package/dist/session-handler/model-utils.js +124 -0
  72. package/dist/session-handler/opencode-session-event-log.js +94 -0
  73. package/dist/session-handler/runtime-types.js +3 -0
  74. package/dist/session-handler/state.js +43 -141
  75. package/dist/session-handler/state.test.js +52 -0
  76. package/dist/session-handler/thread-runtime-state.js +95 -0
  77. package/dist/session-handler/thread-runtime-state.test.js +287 -0
  78. package/dist/session-handler/thread-session-runtime.js +2763 -50
  79. package/dist/session-handler.js +9 -1874
  80. package/dist/startup-service.js +1 -1
  81. package/dist/store.js +16 -0
  82. package/dist/system-message.js +35 -3
  83. package/dist/task-runner.js +3 -2
  84. package/dist/test-utils.js +276 -0
  85. package/dist/thread-message-queue.e2e.test.js +539 -335
  86. package/dist/thread-queue-advanced.e2e.test.js +884 -0
  87. package/dist/token-usage.js +11 -0
  88. package/dist/tools.js +2 -4
  89. package/dist/upgrade.js +1 -1
  90. package/dist/utils.js +29 -2
  91. package/dist/voice-handler.js +34 -3
  92. package/dist/voice-message.e2e.test.js +850 -0
  93. package/dist/worktree-utils.js +3 -727
  94. package/dist/worktrees.js +851 -0
  95. package/dist/worktrees.test.js +189 -0
  96. package/package.json +11 -9
  97. package/schema.prisma +59 -16
  98. package/skills/goke/src/__test__/index.test.ts +7 -0
  99. package/skills/npm-package/SKILL.md +114 -0
  100. package/skills/zustand-centralized-state/SKILL.md +426 -4
  101. package/src/agent-model.e2e.test.ts +897 -0
  102. package/src/channel-management.ts +0 -6
  103. package/src/cli-parsing.test.ts +31 -0
  104. package/src/cli.ts +981 -217
  105. package/src/commands/abort.ts +25 -38
  106. package/src/commands/action-buttons.ts +11 -28
  107. package/src/commands/add-project.ts +0 -3
  108. package/src/commands/agent.ts +89 -24
  109. package/src/commands/create-new-project.ts +12 -8
  110. package/src/commands/diff.ts +1 -1
  111. package/src/commands/login.ts +56 -21
  112. package/src/commands/mention-mode.ts +0 -9
  113. package/src/commands/merge-worktree.ts +15 -36
  114. package/src/commands/model.ts +10 -23
  115. package/src/commands/queue.ts +67 -92
  116. package/src/commands/restart-opencode-server.ts +11 -69
  117. package/src/commands/resume.ts +0 -12
  118. package/src/commands/run-command.ts +1 -1
  119. package/src/commands/session.ts +15 -21
  120. package/src/commands/stop-opencode-server.ts +113 -0
  121. package/src/commands/unset-model.ts +7 -23
  122. package/src/commands/user-command.ts +28 -23
  123. package/src/commands/verbosity.ts +125 -40
  124. package/src/commands/worktree-settings.ts +0 -9
  125. package/src/commands/worktree.ts +7 -35
  126. package/src/commands/worktrees.ts +174 -0
  127. package/src/config.ts +17 -72
  128. package/src/database.ts +206 -65
  129. package/src/db.test.ts +103 -1
  130. package/src/db.ts +49 -0
  131. package/src/debounced-process-flush.ts +104 -0
  132. package/src/discord-bot.ts +252 -357
  133. package/src/discord-urls.ts +76 -0
  134. package/src/discord-utils.ts +2 -5
  135. package/src/event-stream-real-capture.e2e.test.ts +706 -0
  136. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  137. package/src/gateway-proxy.e2e.test.ts +637 -0
  138. package/src/generated/browser.ts +5 -0
  139. package/src/generated/client.ts +8 -1
  140. package/src/generated/commonInputTypes.ts +234 -44
  141. package/src/generated/enums.ts +34 -0
  142. package/src/generated/internal/class.ts +29 -7
  143. package/src/generated/internal/prismaNamespace.ts +134 -7
  144. package/src/generated/internal/prismaNamespaceBrowser.ts +19 -2
  145. package/src/generated/models/bot_tokens.ts +205 -126
  146. package/src/generated/models/channel_directories.ts +44 -289
  147. package/src/generated/models/channel_verbosity.ts +23 -19
  148. package/src/generated/models/channel_worktrees.ts +0 -8
  149. package/src/generated/models/session_events.ts +1439 -0
  150. package/src/generated/models/thread_sessions.ts +130 -0
  151. package/src/generated/models/thread_worktrees.ts +25 -21
  152. package/src/generated/models.ts +1 -0
  153. package/src/hrana-server.ts +11 -4
  154. package/src/interaction-handler.ts +30 -3
  155. package/src/kimaki-digital-twin.e2e.test.ts +30 -15
  156. package/src/logger.ts +10 -6
  157. package/src/markdown.test.ts +236 -287
  158. package/src/message-preprocessing.ts +266 -0
  159. package/src/opencode-interrupt-plugin.test.ts +639 -0
  160. package/src/opencode-interrupt-plugin.ts +294 -0
  161. package/src/opencode-plugin-loading.e2e.test.ts +2 -2
  162. package/src/opencode-plugin.ts +13 -187
  163. package/src/opencode.ts +185 -31
  164. package/src/queue-advanced-abort.e2e.test.ts +384 -0
  165. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  166. package/src/queue-advanced-e2e-setup.ts +446 -0
  167. package/src/queue-advanced-footer.e2e.test.ts +365 -0
  168. package/src/queue-advanced-permissions-typing.e2e.test.ts +141 -0
  169. package/src/queue-advanced-typing.e2e.test.ts +206 -0
  170. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  171. package/src/runtime-idle-sweeper.ts +86 -0
  172. package/src/runtime-lifecycle.e2e.test.ts +578 -0
  173. package/src/schema.sql +21 -7
  174. package/src/sentry.ts +2 -0
  175. package/src/session-handler/agent-utils.ts +97 -0
  176. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  177. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  178. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  179. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  180. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  181. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  182. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  183. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  184. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  185. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  186. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  187. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  188. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  189. package/src/session-handler/event-stream-state.test.ts +501 -0
  190. package/src/session-handler/event-stream-state.ts +539 -0
  191. package/src/session-handler/model-utils.ts +183 -0
  192. package/src/session-handler/opencode-session-event-log.ts +130 -0
  193. package/src/session-handler/thread-runtime-state.ts +183 -0
  194. package/src/session-handler/thread-session-runtime.ts +3669 -0
  195. package/src/session-handler.ts +15 -2668
  196. package/src/startup-service.ts +1 -1
  197. package/src/store.ts +111 -0
  198. package/src/system-message.ts +35 -3
  199. package/src/task-runner.ts +3 -2
  200. package/src/test-utils.ts +412 -0
  201. package/src/thread-message-queue.e2e.test.ts +623 -419
  202. package/src/tools.ts +2 -4
  203. package/src/upgrade.ts +1 -1
  204. package/src/utils.ts +54 -3
  205. package/src/voice-handler.ts +41 -3
  206. package/src/voice-message.e2e.test.ts +1046 -0
  207. package/src/worktree-utils.ts +3 -987
  208. package/src/worktrees.test.ts +223 -0
  209. package/src/worktrees.ts +1156 -0
  210. package/src/__snapshots__/compact-session-context-no-system.md +0 -35
  211. package/src/__snapshots__/compact-session-context.md +0 -41
  212. package/src/__snapshots__/first-session-no-info.md +0 -17
  213. package/src/__snapshots__/first-session-with-info.md +0 -23
  214. package/src/__snapshots__/session-1.md +0 -17
  215. package/src/__snapshots__/session-2.md +0 -5871
  216. package/src/__snapshots__/session-3.md +0 -17
  217. package/src/__snapshots__/session-with-tools.md +0 -5871
  218. package/src/session-handler/state.ts +0 -232
@@ -0,0 +1,694 @@
1
+ // E2e test for agent model resolution in new threads.
2
+ // Reproduces a bug where /agent channel preference is ignored by the
3
+ // promptAsync path: submitViaOpencodeQueue only passes input.agent/input.model
4
+ // (undefined for normal Discord messages) instead of resolving channel agent
5
+ // preferences from DB like dispatchPrompt does.
6
+ //
7
+ // The test sets a channel agent with a custom model, sends a message,
8
+ // and verifies the footer contains the agent's model — not the default.
9
+ //
10
+ // Uses opencode-deterministic-provider (no real LLM calls).
11
+ // Poll timeouts: 4s max, 100ms interval.
12
+ import fs from 'node:fs';
13
+ import net from 'node:net';
14
+ import path from 'node:path';
15
+ import url from 'node:url';
16
+ import { describe, beforeAll, afterAll, test, expect, } from 'vitest';
17
+ import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder } from 'discord.js';
18
+ import { DigitalDiscord } from 'discord-digital-twin/src';
19
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
20
+ import { setDataDir } from './config.js';
21
+ import { store } from './store.js';
22
+ import { startDiscordBot } from './discord-bot.js';
23
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, setChannelAgent, setChannelModel, } from './database.js';
24
+ import { getPrisma } from './db.js';
25
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
26
+ import { initializeOpencodeForDirectory } from './opencode.js';
27
+ import { cleanupOpencodeServers, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
28
+ import { buildQuickAgentCommandDescription } from './commands/agent.js';
29
+ const TEST_USER_ID = '200000000000000920';
30
+ const TEXT_CHANNEL_ID = '200000000000000921';
31
+ const AGENT_MODEL = 'agent-model-v2';
32
+ const PLAN_AGENT_MODEL = 'plan-model-v2';
33
+ const CHANNEL_MODEL = 'channel-model-v2';
34
+ const DEFAULT_MODEL = 'deterministic-v2';
35
+ const PROVIDER_NAME = 'deterministic-provider';
36
+ function createRunDirectories() {
37
+ const root = path.resolve(process.cwd(), 'tmp', 'agent-model-e2e');
38
+ fs.mkdirSync(root, { recursive: true });
39
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
40
+ const projectDirectory = path.join(root, 'project');
41
+ fs.mkdirSync(projectDirectory, { recursive: true });
42
+ return { root, dataDir, projectDirectory };
43
+ }
44
+ function chooseLockPort() {
45
+ return new Promise((resolve, reject) => {
46
+ const server = net.createServer();
47
+ server.listen(0, () => {
48
+ const address = server.address();
49
+ if (!address || typeof address === 'string') {
50
+ server.close();
51
+ reject(new Error('Failed to resolve lock port'));
52
+ return;
53
+ }
54
+ const port = address.port;
55
+ server.close(() => {
56
+ resolve(port);
57
+ });
58
+ });
59
+ });
60
+ }
61
+ function createDiscordJsClient({ restUrl }) {
62
+ return new Client({
63
+ intents: [
64
+ GatewayIntentBits.Guilds,
65
+ GatewayIntentBits.GuildMessages,
66
+ GatewayIntentBits.MessageContent,
67
+ GatewayIntentBits.GuildVoiceStates,
68
+ ],
69
+ partials: [
70
+ Partials.Channel,
71
+ Partials.Message,
72
+ Partials.User,
73
+ Partials.ThreadMember,
74
+ ],
75
+ rest: {
76
+ api: restUrl,
77
+ version: '10',
78
+ },
79
+ });
80
+ }
81
+ function createDeterministicMatchers() {
82
+ const systemContextMatcher = {
83
+ id: 'system-context-check',
84
+ priority: 20,
85
+ when: {
86
+ lastMessageRole: 'user',
87
+ latestUserTextIncludes: 'Reply with exactly: system-context-check',
88
+ rawPromptIncludes: `Current Discord user ID is: ${TEST_USER_ID}`,
89
+ },
90
+ then: {
91
+ parts: [
92
+ { type: 'stream-start', warnings: [] },
93
+ { type: 'text-start', id: 'system-context-reply' },
94
+ {
95
+ type: 'text-delta',
96
+ id: 'system-context-reply',
97
+ delta: 'system-context-ok',
98
+ },
99
+ { type: 'text-end', id: 'system-context-reply' },
100
+ {
101
+ type: 'finish',
102
+ finishReason: 'stop',
103
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
104
+ },
105
+ ],
106
+ partDelaysMs: [0, 100, 0, 0, 0],
107
+ },
108
+ };
109
+ const userReplyMatcher = {
110
+ id: 'user-reply',
111
+ priority: 10,
112
+ when: {
113
+ lastMessageRole: 'user',
114
+ latestUserTextIncludes: 'Reply with exactly:',
115
+ },
116
+ then: {
117
+ parts: [
118
+ { type: 'stream-start', warnings: [] },
119
+ { type: 'text-start', id: 'default-reply' },
120
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
121
+ { type: 'text-end', id: 'default-reply' },
122
+ {
123
+ type: 'finish',
124
+ finishReason: 'stop',
125
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
126
+ },
127
+ ],
128
+ partDelaysMs: [0, 100, 0, 0, 0],
129
+ },
130
+ };
131
+ return [systemContextMatcher, userReplyMatcher];
132
+ }
133
+ /**
134
+ * Create an opencode agent .md file that uses a specific model.
135
+ * OpenCode discovers agents from .opencode/agent/*.md files.
136
+ */
137
+ function createAgentFile({ projectDirectory, agentName, model, }) {
138
+ const agentDir = path.join(projectDirectory, '.opencode', 'agent');
139
+ fs.mkdirSync(agentDir, { recursive: true });
140
+ const content = [
141
+ '---',
142
+ `model: ${model}`,
143
+ 'mode: primary',
144
+ `description: Test agent with custom model`,
145
+ '---',
146
+ '',
147
+ 'You are a test agent. Reply concisely.',
148
+ '',
149
+ ].join('\n');
150
+ fs.writeFileSync(path.join(agentDir, `${agentName}.md`), content);
151
+ }
152
+ describe('agent model resolution', () => {
153
+ let directories;
154
+ let discord;
155
+ let botClient;
156
+ let previousDefaultVerbosity = null;
157
+ let testStartTime = Date.now();
158
+ beforeAll(async () => {
159
+ testStartTime = Date.now();
160
+ directories = createRunDirectories();
161
+ const lockPort = await chooseLockPort();
162
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
163
+ setDataDir(directories.dataDir);
164
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
165
+ store.setState({ defaultVerbosity: 'tools_and_text' });
166
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
167
+ discord = new DigitalDiscord({
168
+ guild: {
169
+ name: 'Agent Model E2E Guild',
170
+ ownerId: TEST_USER_ID,
171
+ },
172
+ channels: [
173
+ {
174
+ id: TEXT_CHANNEL_ID,
175
+ name: 'agent-model-e2e',
176
+ type: ChannelType.GuildText,
177
+ },
178
+ ],
179
+ users: [
180
+ {
181
+ id: TEST_USER_ID,
182
+ username: 'agent-model-tester',
183
+ },
184
+ ],
185
+ dbUrl: `file:${digitalDiscordDbPath}`,
186
+ });
187
+ await discord.start();
188
+ const providerNpm = url
189
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
190
+ .toString();
191
+ // Build base config with default model
192
+ const opencodeConfig = buildDeterministicOpencodeConfig({
193
+ providerName: PROVIDER_NAME,
194
+ providerNpm,
195
+ model: DEFAULT_MODEL,
196
+ smallModel: DEFAULT_MODEL,
197
+ settings: {
198
+ strict: false,
199
+ matchers: createDeterministicMatchers(),
200
+ },
201
+ });
202
+ // Add extra models to the provider so opencode accepts them
203
+ const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
204
+ providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
205
+ providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
206
+ providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
207
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
208
+ // Create agent .md files with custom models
209
+ createAgentFile({
210
+ projectDirectory: directories.projectDirectory,
211
+ agentName: 'test-agent',
212
+ model: `${PROVIDER_NAME}/${AGENT_MODEL}`,
213
+ });
214
+ createAgentFile({
215
+ projectDirectory: directories.projectDirectory,
216
+ agentName: 'plan',
217
+ model: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
218
+ });
219
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
220
+ const hranaResult = await startHranaServer({ dbPath });
221
+ if (hranaResult instanceof Error) {
222
+ throw hranaResult;
223
+ }
224
+ process.env['KIMAKI_DB_URL'] = hranaResult;
225
+ await initDatabase();
226
+ await setBotToken(discord.botUserId, discord.botToken);
227
+ await setChannelDirectory({
228
+ channelId: TEXT_CHANNEL_ID,
229
+ directory: directories.projectDirectory,
230
+ channelType: 'text',
231
+ });
232
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
233
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
234
+ await startDiscordBot({
235
+ token: discord.botToken,
236
+ appId: discord.botUserId,
237
+ discordClient: botClient,
238
+ });
239
+ // Register quick agent slash commands so /plan-agent and /test-agent-agent
240
+ // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
241
+ const agentCommands = ['test-agent', 'plan'].map((agentName) => {
242
+ return new SlashCommandBuilder()
243
+ .setName(`${agentName}-agent`)
244
+ .setDescription(buildQuickAgentCommandDescription({
245
+ agentName,
246
+ description: `Switch to ${agentName} agent`,
247
+ }))
248
+ .setDMPermission(false)
249
+ .toJSON();
250
+ });
251
+ const rest = new REST({ version: '10', api: discord.restUrl }).setToken(discord.botToken);
252
+ await rest.put(Routes.applicationGuildCommands(discord.botUserId, discord.guildId), { body: agentCommands });
253
+ // Pre-warm the opencode server so agent discovery happens
254
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
255
+ if (warmup instanceof Error) {
256
+ throw warmup;
257
+ }
258
+ }, 60_000);
259
+ afterAll(async () => {
260
+ if (directories) {
261
+ await cleanupTestSessions({
262
+ projectDirectory: directories.projectDirectory,
263
+ testStartTime,
264
+ });
265
+ }
266
+ if (botClient) {
267
+ botClient.destroy();
268
+ }
269
+ await cleanupOpencodeServers();
270
+ await Promise.all([
271
+ closeDatabase().catch(() => {
272
+ return;
273
+ }),
274
+ stopHranaServer().catch(() => {
275
+ return;
276
+ }),
277
+ discord?.stop().catch(() => {
278
+ return;
279
+ }),
280
+ ]);
281
+ delete process.env['KIMAKI_LOCK_PORT'];
282
+ delete process.env['KIMAKI_DB_URL'];
283
+ if (previousDefaultVerbosity) {
284
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
285
+ }
286
+ if (directories) {
287
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
288
+ }
289
+ }, 10_000);
290
+ test('new thread uses agent model when channel agent is set', async () => {
291
+ // Set channel agent preference — this simulates /agent selecting test-agent
292
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
293
+ // Send a message to create a new thread
294
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
295
+ content: 'Reply with exactly: agent-model-check',
296
+ });
297
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
298
+ timeout: 4_000,
299
+ predicate: (t) => {
300
+ return t.name === 'Reply with exactly: agent-model-check';
301
+ },
302
+ });
303
+ // Wait for the footer (starts with *project) — proves run completed.
304
+ // Then assert which model ID appears in it.
305
+ await waitForBotMessageContaining({
306
+ discord,
307
+ threadId: thread.id,
308
+ userId: TEST_USER_ID,
309
+ text: '*project',
310
+ timeout: 4_000,
311
+ });
312
+ const messages = await discord.thread(thread.id).getMessages();
313
+ // Find the footer message (starts with * italic)
314
+ const footerMessage = messages.find((message) => {
315
+ return (message.author.id === discord.botUserId &&
316
+ message.content.startsWith('*'));
317
+ });
318
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
319
+ "--- from: user (agent-model-tester)
320
+ Reply with exactly: agent-model-check
321
+ --- from: assistant (TestBot)
322
+ ⬥ ok
323
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
324
+ `);
325
+ expect(footerMessage).toBeDefined();
326
+ if (!footerMessage) {
327
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
328
+ .filter((m) => m.author.id === discord.botUserId)
329
+ .map((m) => m.content.slice(0, 150))
330
+ .join(' | ')}`);
331
+ }
332
+ // The footer should contain the agent's model, not the default
333
+ expect(footerMessage.content).toContain(AGENT_MODEL);
334
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
335
+ }, 15_000);
336
+ test('promptAsync path includes rich system context', async () => {
337
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
338
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
339
+ content: 'Reply with exactly: system-context-check',
340
+ });
341
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
342
+ timeout: 4_000,
343
+ predicate: (t) => {
344
+ return t.name === 'Reply with exactly: system-context-check';
345
+ },
346
+ });
347
+ await waitForBotMessageContaining({
348
+ discord,
349
+ threadId: thread.id,
350
+ userId: TEST_USER_ID,
351
+ text: 'system-context-ok',
352
+ timeout: 4_000,
353
+ });
354
+ await waitForFooterMessage({
355
+ discord,
356
+ threadId: thread.id,
357
+ timeout: 4_000,
358
+ afterMessageIncludes: 'system-context-ok',
359
+ afterAuthorId: discord.botUserId,
360
+ });
361
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
362
+ "--- from: user (agent-model-tester)
363
+ Reply with exactly: system-context-check
364
+ --- from: assistant (TestBot)
365
+ ⬥ system-context-ok
366
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
367
+ `);
368
+ }, 15_000);
369
+ test('new thread uses channel model when channel model preference is set', async () => {
370
+ // Clear channel agent so model resolution falls through to channel model
371
+ const prisma = await getPrisma();
372
+ await prisma.channel_agents.deleteMany({
373
+ where: { channel_id: TEXT_CHANNEL_ID },
374
+ });
375
+ // Set channel model preference — simulates /model selecting a model at channel scope
376
+ await setChannelModel({
377
+ channelId: TEXT_CHANNEL_ID,
378
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
379
+ });
380
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
381
+ content: 'Reply with exactly: channel-model-check',
382
+ });
383
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
384
+ timeout: 4_000,
385
+ predicate: (t) => {
386
+ return t.name === 'Reply with exactly: channel-model-check';
387
+ },
388
+ });
389
+ await waitForBotMessageContaining({
390
+ discord,
391
+ threadId: thread.id,
392
+ userId: TEST_USER_ID,
393
+ text: '*project',
394
+ timeout: 4_000,
395
+ });
396
+ const messages = await discord.thread(thread.id).getMessages();
397
+ const footerMessage = messages.find((message) => {
398
+ return (message.author.id === discord.botUserId &&
399
+ message.content.startsWith('*'));
400
+ });
401
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
402
+ "--- from: user (agent-model-tester)
403
+ Reply with exactly: channel-model-check
404
+ --- from: assistant (TestBot)
405
+ ⬥ ok
406
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
407
+ `);
408
+ expect(footerMessage).toBeDefined();
409
+ if (!footerMessage) {
410
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
411
+ .filter((m) => m.author.id === discord.botUserId)
412
+ .map((m) => m.content.slice(0, 150))
413
+ .join(' | ')}`);
414
+ }
415
+ // Footer should contain the channel model, not the default
416
+ expect(footerMessage.content).toContain(CHANNEL_MODEL);
417
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
418
+ }, 15_000);
419
+ test('channel model with variant preference completes without error', async () => {
420
+ // Clear channel agent so model resolution falls through to channel model
421
+ const prisma = await getPrisma();
422
+ await prisma.channel_agents.deleteMany({
423
+ where: { channel_id: TEXT_CHANNEL_ID },
424
+ });
425
+ // Set channel model with a variant (thinking level)
426
+ // The deterministic provider doesn't support thinking, so the variant
427
+ // is resolved but silently dropped (no matching thinking values).
428
+ // This test verifies the variant cascade code path runs without crashing
429
+ // and the correct model still appears in the footer.
430
+ await setChannelModel({
431
+ channelId: TEXT_CHANNEL_ID,
432
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
433
+ variant: 'high',
434
+ });
435
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
436
+ content: 'Reply with exactly: variant-check',
437
+ });
438
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
439
+ timeout: 4_000,
440
+ predicate: (t) => {
441
+ return t.name === 'Reply with exactly: variant-check';
442
+ },
443
+ });
444
+ await waitForBotMessageContaining({
445
+ discord,
446
+ threadId: thread.id,
447
+ userId: TEST_USER_ID,
448
+ text: '*project',
449
+ timeout: 4_000,
450
+ });
451
+ const messages = await discord.thread(thread.id).getMessages();
452
+ const footerMessage = messages.find((message) => {
453
+ return (message.author.id === discord.botUserId &&
454
+ message.content.startsWith('*'));
455
+ });
456
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
457
+ "--- from: user (agent-model-tester)
458
+ Reply with exactly: variant-check
459
+ --- from: assistant (TestBot)
460
+ ⬥ ok
461
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
462
+ `);
463
+ expect(footerMessage).toBeDefined();
464
+ if (!footerMessage) {
465
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
466
+ .filter((m) => m.author.id === discord.botUserId)
467
+ .map((m) => m.content.slice(0, 150))
468
+ .join(' | ')}`);
469
+ }
470
+ // Footer should still contain the channel model (variant doesn't crash)
471
+ expect(footerMessage.content).toContain(CHANNEL_MODEL);
472
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
473
+ }, 15_000);
474
+ test('changing channel agent via /plan-agent does not affect existing thread model', async () => {
475
+ // 1. Set channel agent to test-agent (uses AGENT_MODEL)
476
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
477
+ // 2. Send a message to create a thread
478
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
479
+ content: 'Reply with exactly: first-thread-msg',
480
+ });
481
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
482
+ timeout: 4_000,
483
+ predicate: (t) => {
484
+ return t.name === 'Reply with exactly: first-thread-msg';
485
+ },
486
+ });
487
+ // Wait for footer — proves first run completed with test-agent's model
488
+ await waitForFooterMessage({
489
+ discord,
490
+ threadId: thread.id,
491
+ timeout: 4_000,
492
+ afterMessageIncludes: 'ok',
493
+ afterAuthorId: discord.botUserId,
494
+ });
495
+ const firstMessages = await discord.thread(thread.id).getMessages();
496
+ const firstFooter = firstMessages.find((m) => {
497
+ return (m.author.id === discord.botUserId && m.content.startsWith('*'));
498
+ });
499
+ expect(firstFooter).toBeDefined();
500
+ // Verify the first run used test-agent's model
501
+ expect(firstFooter.content).toContain(AGENT_MODEL);
502
+ // 3. Switch channel agent to plan via /plan-agent in the CHANNEL
503
+ const { id: interactionId } = await discord
504
+ .channel(TEXT_CHANNEL_ID)
505
+ .user(TEST_USER_ID)
506
+ .runSlashCommand({ name: 'plan-agent' });
507
+ await discord
508
+ .channel(TEXT_CHANNEL_ID)
509
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
510
+ // 4. Send a second message in the EXISTING thread
511
+ const th = discord.thread(thread.id);
512
+ await th.user(TEST_USER_ID).sendMessage({
513
+ content: 'Reply with exactly: second-thread-msg',
514
+ });
515
+ // Wait for second footer (anchor on the user message, not bot reply)
516
+ await waitForFooterMessage({
517
+ discord,
518
+ threadId: thread.id,
519
+ timeout: 4_000,
520
+ afterMessageIncludes: 'second-thread-msg',
521
+ afterAuthorId: TEST_USER_ID,
522
+ });
523
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
524
+ "--- from: user (agent-model-tester)
525
+ Reply with exactly: first-thread-msg
526
+ --- from: assistant (TestBot)
527
+ ⬥ ok
528
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
529
+ --- from: user (agent-model-tester)
530
+ Reply with exactly: second-thread-msg
531
+ --- from: assistant (TestBot)
532
+ ⬥ ok
533
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
534
+ `);
535
+ const secondMessages = await discord.thread(thread.id).getMessages();
536
+ const secondFooter = [...secondMessages]
537
+ .reverse()
538
+ .find((m) => {
539
+ return (m.author.id === discord.botUserId && m.content.startsWith('*'));
540
+ });
541
+ expect(secondFooter).toBeDefined();
542
+ // The existing thread should still use test-agent's model (AGENT_MODEL),
543
+ // NOT plan agent's model (PLAN_AGENT_MODEL)
544
+ expect(secondFooter.content).toContain(AGENT_MODEL);
545
+ expect(secondFooter.content).not.toContain(PLAN_AGENT_MODEL);
546
+ }, 20_000);
547
+ test('thread created with no agent keeps default model after channel agent is set', async () => {
548
+ // Clear any channel agent — thread starts with default (no agent)
549
+ const prisma = await getPrisma();
550
+ await prisma.channel_agents.deleteMany({
551
+ where: { channel_id: TEXT_CHANNEL_ID },
552
+ });
553
+ // Also clear channel model so we get the pure default
554
+ await prisma.channel_models.deleteMany({
555
+ where: { channel_id: TEXT_CHANNEL_ID },
556
+ });
557
+ // 1. Send a message to create a thread (no channel agent set)
558
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
559
+ content: 'Reply with exactly: default-thread-msg',
560
+ });
561
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
562
+ timeout: 4_000,
563
+ predicate: (t) => {
564
+ return t.name === 'Reply with exactly: default-thread-msg';
565
+ },
566
+ });
567
+ // Wait for footer — should show the default model
568
+ await waitForFooterMessage({
569
+ discord,
570
+ threadId: thread.id,
571
+ timeout: 4_000,
572
+ afterMessageIncludes: 'ok',
573
+ afterAuthorId: discord.botUserId,
574
+ });
575
+ const firstMessages = await discord.thread(thread.id).getMessages();
576
+ const firstFooter = firstMessages.find((m) => {
577
+ return (m.author.id === discord.botUserId && m.content.startsWith('*'));
578
+ });
579
+ expect(firstFooter).toBeDefined();
580
+ // First run uses the default model (no agent set)
581
+ expect(firstFooter.content).toContain(DEFAULT_MODEL);
582
+ expect(firstFooter.content).not.toContain(AGENT_MODEL);
583
+ // 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
584
+ const { id: interactionId } = await discord
585
+ .channel(TEXT_CHANNEL_ID)
586
+ .user(TEST_USER_ID)
587
+ .runSlashCommand({ name: 'test-agent-agent' });
588
+ await discord
589
+ .channel(TEXT_CHANNEL_ID)
590
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
591
+ // 3. Send a second message in the EXISTING thread
592
+ await discord
593
+ .thread(thread.id)
594
+ .user(TEST_USER_ID)
595
+ .sendMessage({
596
+ content: 'Reply with exactly: default-second-msg',
597
+ });
598
+ await waitForFooterMessage({
599
+ discord,
600
+ threadId: thread.id,
601
+ timeout: 4_000,
602
+ afterMessageIncludes: 'default-second-msg',
603
+ afterAuthorId: TEST_USER_ID,
604
+ });
605
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
606
+ "--- from: user (agent-model-tester)
607
+ Reply with exactly: default-thread-msg
608
+ --- from: assistant (TestBot)
609
+ ⬥ ok
610
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
611
+ --- from: user (agent-model-tester)
612
+ Reply with exactly: default-second-msg
613
+ --- from: assistant (TestBot)
614
+ ⬥ ok
615
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
616
+ `);
617
+ const secondMessages = await discord.thread(thread.id).getMessages();
618
+ const secondFooter = [...secondMessages]
619
+ .reverse()
620
+ .find((m) => {
621
+ return (m.author.id === discord.botUserId && m.content.startsWith('*'));
622
+ });
623
+ expect(secondFooter).toBeDefined();
624
+ // The existing thread should still use the DEFAULT model,
625
+ // NOT the test-agent's model (AGENT_MODEL)
626
+ expect(secondFooter.content).toContain(DEFAULT_MODEL);
627
+ expect(secondFooter.content).not.toContain(AGENT_MODEL);
628
+ }, 20_000);
629
+ test('/plan-agent inside a thread switches the model for that thread', async () => {
630
+ // 1. Start with test-agent on the channel
631
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
632
+ // 2. Create a thread — first run uses test-agent's model
633
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
634
+ content: 'Reply with exactly: switch-in-thread-msg',
635
+ });
636
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
637
+ timeout: 4_000,
638
+ predicate: (t) => {
639
+ return t.name === 'Reply with exactly: switch-in-thread-msg';
640
+ },
641
+ });
642
+ await waitForFooterMessage({
643
+ discord,
644
+ threadId: thread.id,
645
+ timeout: 4_000,
646
+ afterMessageIncludes: 'ok',
647
+ afterAuthorId: discord.botUserId,
648
+ });
649
+ const firstFooter = (await discord.thread(thread.id).getMessages()).find((m) => {
650
+ return (m.author.id === discord.botUserId && m.content.startsWith('*'));
651
+ });
652
+ expect(firstFooter).toBeDefined();
653
+ expect(firstFooter.content).toContain(AGENT_MODEL);
654
+ // 3. Run /plan-agent INSIDE the thread
655
+ const th = discord.thread(thread.id);
656
+ const { id: interactionId } = await th
657
+ .user(TEST_USER_ID)
658
+ .runSlashCommand({ name: 'plan-agent' });
659
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 });
660
+ // 4. Send a second message in the same thread
661
+ await th.user(TEST_USER_ID).sendMessage({
662
+ content: 'Reply with exactly: after-switch-msg',
663
+ });
664
+ await waitForFooterMessage({
665
+ discord,
666
+ threadId: thread.id,
667
+ timeout: 4_000,
668
+ afterMessageIncludes: 'after-switch-msg',
669
+ afterAuthorId: TEST_USER_ID,
670
+ });
671
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
672
+ "--- from: user (agent-model-tester)
673
+ Reply with exactly: switch-in-thread-msg
674
+ --- from: assistant (TestBot)
675
+ ⬥ ok
676
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
677
+ Switched to **plan** agent for this session (was **test-agent**)
678
+ --- from: user (agent-model-tester)
679
+ Reply with exactly: after-switch-msg
680
+ --- from: assistant (TestBot)
681
+ ⬥ ok
682
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
683
+ `);
684
+ const secondFooter = [...(await discord.thread(thread.id).getMessages())]
685
+ .reverse()
686
+ .find((m) => {
687
+ return (m.author.id === discord.botUserId && m.content.startsWith('*'));
688
+ });
689
+ expect(secondFooter).toBeDefined();
690
+ // After /plan-agent in the thread, model should switch to plan's model
691
+ expect(secondFooter.content).toContain(PLAN_AGENT_MODEL);
692
+ expect(secondFooter.content).not.toContain(AGENT_MODEL);
693
+ }, 20_000);
694
+ });