kimaki 0.4.72 → 0.4.73

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 +452 -0
  2. package/dist/channel-management.js +1 -4
  3. package/dist/cli-parsing.test.js +25 -0
  4. package/dist/cli.js +663 -163
  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 +128 -0
  28. package/dist/config.js +16 -47
  29. package/dist/database.js +174 -43
  30. package/dist/db.js +47 -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 +17 -5
  43. package/dist/generated/internal/prismaNamespaceBrowser.js +13 -1
  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 +183 -0
  53. package/dist/opencode-interrupt-plugin.test.js +334 -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 +6 -7
  57. package/dist/opencode.js +152 -22
  58. package/dist/queue-advanced-abort.e2e.test.js +299 -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 +17 -0
  82. package/dist/system-message.js +19 -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 +542 -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 +845 -0
  95. package/dist/worktrees.test.js +189 -0
  96. package/package.json +12 -10
  97. package/schema.prisma +58 -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 +585 -0
  102. package/src/channel-management.ts +0 -6
  103. package/src/cli-parsing.test.ts +31 -0
  104. package/src/cli.ts +872 -199
  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 +173 -0
  127. package/src/config.ts +17 -72
  128. package/src/database.ts +227 -65
  129. package/src/db.test.ts +103 -1
  130. package/src/db.ts +48 -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 +132 -6
  144. package/src/generated/internal/prismaNamespaceBrowser.ts +17 -1
  145. package/src/generated/models/bot_tokens.ts +165 -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 +445 -0
  160. package/src/opencode-interrupt-plugin.ts +237 -0
  161. package/src/opencode-plugin-loading.e2e.test.ts +2 -2
  162. package/src/opencode-plugin.ts +11 -5
  163. package/src/opencode.ts +186 -26
  164. package/src/queue-advanced-abort.e2e.test.ts +387 -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 +19 -6
  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 +118 -0
  198. package/src/system-message.ts +19 -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 +626 -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 +1152 -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,452 @@
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 } 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
+ const TEST_USER_ID = '200000000000000920';
29
+ const TEXT_CHANNEL_ID = '200000000000000921';
30
+ const AGENT_MODEL = 'agent-model-v2';
31
+ const CHANNEL_MODEL = 'channel-model-v2';
32
+ const DEFAULT_MODEL = 'deterministic-v2';
33
+ const PROVIDER_NAME = 'deterministic-provider';
34
+ function createRunDirectories() {
35
+ const root = path.resolve(process.cwd(), 'tmp', 'agent-model-e2e');
36
+ fs.mkdirSync(root, { recursive: true });
37
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
38
+ const projectDirectory = path.join(root, 'project');
39
+ fs.mkdirSync(projectDirectory, { recursive: true });
40
+ return { root, dataDir, projectDirectory };
41
+ }
42
+ function chooseLockPort() {
43
+ return new Promise((resolve, reject) => {
44
+ const server = net.createServer();
45
+ server.listen(0, () => {
46
+ const address = server.address();
47
+ if (!address || typeof address === 'string') {
48
+ server.close();
49
+ reject(new Error('Failed to resolve lock port'));
50
+ return;
51
+ }
52
+ const port = address.port;
53
+ server.close(() => {
54
+ resolve(port);
55
+ });
56
+ });
57
+ });
58
+ }
59
+ function createDiscordJsClient({ restUrl }) {
60
+ return new Client({
61
+ intents: [
62
+ GatewayIntentBits.Guilds,
63
+ GatewayIntentBits.GuildMessages,
64
+ GatewayIntentBits.MessageContent,
65
+ GatewayIntentBits.GuildVoiceStates,
66
+ ],
67
+ partials: [
68
+ Partials.Channel,
69
+ Partials.Message,
70
+ Partials.User,
71
+ Partials.ThreadMember,
72
+ ],
73
+ rest: {
74
+ api: restUrl,
75
+ version: '10',
76
+ },
77
+ });
78
+ }
79
+ function createDeterministicMatchers() {
80
+ const systemContextMatcher = {
81
+ id: 'system-context-check',
82
+ priority: 20,
83
+ when: {
84
+ lastMessageRole: 'user',
85
+ latestUserTextIncludes: 'Reply with exactly: system-context-check',
86
+ rawPromptIncludes: `Current Discord user ID is: ${TEST_USER_ID}`,
87
+ },
88
+ then: {
89
+ parts: [
90
+ { type: 'stream-start', warnings: [] },
91
+ { type: 'text-start', id: 'system-context-reply' },
92
+ {
93
+ type: 'text-delta',
94
+ id: 'system-context-reply',
95
+ delta: 'system-context-ok',
96
+ },
97
+ { type: 'text-end', id: 'system-context-reply' },
98
+ {
99
+ type: 'finish',
100
+ finishReason: 'stop',
101
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
102
+ },
103
+ ],
104
+ partDelaysMs: [0, 100, 0, 0, 0],
105
+ },
106
+ };
107
+ const userReplyMatcher = {
108
+ id: 'user-reply',
109
+ priority: 10,
110
+ when: {
111
+ lastMessageRole: 'user',
112
+ latestUserTextIncludes: 'Reply with exactly:',
113
+ },
114
+ then: {
115
+ parts: [
116
+ { type: 'stream-start', warnings: [] },
117
+ { type: 'text-start', id: 'default-reply' },
118
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
119
+ { type: 'text-end', id: 'default-reply' },
120
+ {
121
+ type: 'finish',
122
+ finishReason: 'stop',
123
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
124
+ },
125
+ ],
126
+ partDelaysMs: [0, 100, 0, 0, 0],
127
+ },
128
+ };
129
+ return [systemContextMatcher, userReplyMatcher];
130
+ }
131
+ /**
132
+ * Create an opencode agent .md file that uses a specific model.
133
+ * OpenCode discovers agents from .opencode/agent/*.md files.
134
+ */
135
+ function createAgentFile({ projectDirectory, agentName, model, }) {
136
+ const agentDir = path.join(projectDirectory, '.opencode', 'agent');
137
+ fs.mkdirSync(agentDir, { recursive: true });
138
+ const content = [
139
+ '---',
140
+ `model: ${model}`,
141
+ 'mode: primary',
142
+ `description: Test agent with custom model`,
143
+ '---',
144
+ '',
145
+ 'You are a test agent. Reply concisely.',
146
+ '',
147
+ ].join('\n');
148
+ fs.writeFileSync(path.join(agentDir, `${agentName}.md`), content);
149
+ }
150
+ describe('agent model resolution', () => {
151
+ let directories;
152
+ let discord;
153
+ let botClient;
154
+ let previousDefaultVerbosity = null;
155
+ let testStartTime = Date.now();
156
+ beforeAll(async () => {
157
+ testStartTime = Date.now();
158
+ directories = createRunDirectories();
159
+ const lockPort = await chooseLockPort();
160
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
161
+ setDataDir(directories.dataDir);
162
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
163
+ store.setState({ defaultVerbosity: 'tools_and_text' });
164
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
165
+ discord = new DigitalDiscord({
166
+ guild: {
167
+ name: 'Agent Model E2E Guild',
168
+ ownerId: TEST_USER_ID,
169
+ },
170
+ channels: [
171
+ {
172
+ id: TEXT_CHANNEL_ID,
173
+ name: 'agent-model-e2e',
174
+ type: ChannelType.GuildText,
175
+ },
176
+ ],
177
+ users: [
178
+ {
179
+ id: TEST_USER_ID,
180
+ username: 'agent-model-tester',
181
+ },
182
+ ],
183
+ dbUrl: `file:${digitalDiscordDbPath}`,
184
+ });
185
+ await discord.start();
186
+ const providerNpm = url
187
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
188
+ .toString();
189
+ // Build base config with default model
190
+ const opencodeConfig = buildDeterministicOpencodeConfig({
191
+ providerName: PROVIDER_NAME,
192
+ providerNpm,
193
+ model: DEFAULT_MODEL,
194
+ smallModel: DEFAULT_MODEL,
195
+ settings: {
196
+ strict: false,
197
+ matchers: createDeterministicMatchers(),
198
+ },
199
+ });
200
+ // Add extra models to the provider so opencode accepts them
201
+ const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
202
+ providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL };
203
+ providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
204
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
205
+ // Create the agent .md file with custom model
206
+ createAgentFile({
207
+ projectDirectory: directories.projectDirectory,
208
+ agentName: 'test-agent',
209
+ model: `${PROVIDER_NAME}/${AGENT_MODEL}`,
210
+ });
211
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
212
+ const hranaResult = await startHranaServer({ dbPath });
213
+ if (hranaResult instanceof Error) {
214
+ throw hranaResult;
215
+ }
216
+ process.env['KIMAKI_DB_URL'] = hranaResult;
217
+ await initDatabase();
218
+ await setBotToken(discord.botUserId, discord.botToken);
219
+ await setChannelDirectory({
220
+ channelId: TEXT_CHANNEL_ID,
221
+ directory: directories.projectDirectory,
222
+ channelType: 'text',
223
+ });
224
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
225
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
226
+ await startDiscordBot({
227
+ token: discord.botToken,
228
+ appId: discord.botUserId,
229
+ discordClient: botClient,
230
+ });
231
+ // Pre-warm the opencode server so agent discovery happens
232
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
233
+ if (warmup instanceof Error) {
234
+ throw warmup;
235
+ }
236
+ }, 60_000);
237
+ afterAll(async () => {
238
+ if (directories) {
239
+ await cleanupTestSessions({
240
+ projectDirectory: directories.projectDirectory,
241
+ testStartTime,
242
+ });
243
+ }
244
+ if (botClient) {
245
+ botClient.destroy();
246
+ }
247
+ await cleanupOpencodeServers();
248
+ await Promise.all([
249
+ closeDatabase().catch(() => {
250
+ return;
251
+ }),
252
+ stopHranaServer().catch(() => {
253
+ return;
254
+ }),
255
+ discord?.stop().catch(() => {
256
+ return;
257
+ }),
258
+ ]);
259
+ delete process.env['KIMAKI_LOCK_PORT'];
260
+ delete process.env['KIMAKI_DB_URL'];
261
+ if (previousDefaultVerbosity) {
262
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
263
+ }
264
+ if (directories) {
265
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
266
+ }
267
+ }, 10_000);
268
+ test('new thread uses agent model when channel agent is set', async () => {
269
+ // Set channel agent preference — this simulates /agent selecting test-agent
270
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
271
+ // Send a message to create a new thread
272
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
273
+ content: 'Reply with exactly: agent-model-check',
274
+ });
275
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
276
+ timeout: 4_000,
277
+ predicate: (t) => {
278
+ return t.name === 'Reply with exactly: agent-model-check';
279
+ },
280
+ });
281
+ // Wait for the footer (starts with *project) — proves run completed.
282
+ // Then assert which model ID appears in it.
283
+ await waitForBotMessageContaining({
284
+ discord,
285
+ threadId: thread.id,
286
+ userId: TEST_USER_ID,
287
+ text: '*project',
288
+ timeout: 4_000,
289
+ });
290
+ const messages = await discord.thread(thread.id).getMessages();
291
+ // Find the footer message (starts with * italic)
292
+ const footerMessage = messages.find((message) => {
293
+ return (message.author.id === discord.botUserId &&
294
+ message.content.startsWith('*'));
295
+ });
296
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
297
+ "--- from: user (agent-model-tester)
298
+ Reply with exactly: agent-model-check
299
+ --- from: assistant (TestBot)
300
+ ⬥ ok
301
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
302
+ `);
303
+ expect(footerMessage).toBeDefined();
304
+ if (!footerMessage) {
305
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
306
+ .filter((m) => m.author.id === discord.botUserId)
307
+ .map((m) => m.content.slice(0, 150))
308
+ .join(' | ')}`);
309
+ }
310
+ // The footer should contain the agent's model, not the default
311
+ expect(footerMessage.content).toContain(AGENT_MODEL);
312
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
313
+ }, 15_000);
314
+ test('promptAsync path includes rich system context', async () => {
315
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent');
316
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
317
+ content: 'Reply with exactly: system-context-check',
318
+ });
319
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
320
+ timeout: 4_000,
321
+ predicate: (t) => {
322
+ return t.name === 'Reply with exactly: system-context-check';
323
+ },
324
+ });
325
+ await waitForBotMessageContaining({
326
+ discord,
327
+ threadId: thread.id,
328
+ userId: TEST_USER_ID,
329
+ text: 'system-context-ok',
330
+ timeout: 4_000,
331
+ });
332
+ await waitForFooterMessage({
333
+ discord,
334
+ threadId: thread.id,
335
+ timeout: 4_000,
336
+ afterMessageIncludes: 'system-context-ok',
337
+ afterAuthorId: discord.botUserId,
338
+ });
339
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
340
+ "--- from: user (agent-model-tester)
341
+ Reply with exactly: system-context-check
342
+ --- from: assistant (TestBot)
343
+ ⬥ system-context-ok
344
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
345
+ `);
346
+ }, 15_000);
347
+ test('new thread uses channel model when channel model preference is set', async () => {
348
+ // Clear channel agent so model resolution falls through to channel model
349
+ const prisma = await getPrisma();
350
+ await prisma.channel_agents.deleteMany({
351
+ where: { channel_id: TEXT_CHANNEL_ID },
352
+ });
353
+ // Set channel model preference — simulates /model selecting a model at channel scope
354
+ await setChannelModel({
355
+ channelId: TEXT_CHANNEL_ID,
356
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
357
+ });
358
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
359
+ content: 'Reply with exactly: channel-model-check',
360
+ });
361
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
362
+ timeout: 4_000,
363
+ predicate: (t) => {
364
+ return t.name === 'Reply with exactly: channel-model-check';
365
+ },
366
+ });
367
+ await waitForBotMessageContaining({
368
+ discord,
369
+ threadId: thread.id,
370
+ userId: TEST_USER_ID,
371
+ text: '*project',
372
+ timeout: 4_000,
373
+ });
374
+ const messages = await discord.thread(thread.id).getMessages();
375
+ const footerMessage = messages.find((message) => {
376
+ return (message.author.id === discord.botUserId &&
377
+ message.content.startsWith('*'));
378
+ });
379
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
380
+ "--- from: user (agent-model-tester)
381
+ Reply with exactly: channel-model-check
382
+ --- from: assistant (TestBot)
383
+ ⬥ ok
384
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
385
+ `);
386
+ expect(footerMessage).toBeDefined();
387
+ if (!footerMessage) {
388
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
389
+ .filter((m) => m.author.id === discord.botUserId)
390
+ .map((m) => m.content.slice(0, 150))
391
+ .join(' | ')}`);
392
+ }
393
+ // Footer should contain the channel model, not the default
394
+ expect(footerMessage.content).toContain(CHANNEL_MODEL);
395
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
396
+ }, 15_000);
397
+ test('channel model with variant preference completes without error', async () => {
398
+ // Clear channel agent so model resolution falls through to channel model
399
+ const prisma = await getPrisma();
400
+ await prisma.channel_agents.deleteMany({
401
+ where: { channel_id: TEXT_CHANNEL_ID },
402
+ });
403
+ // Set channel model with a variant (thinking level)
404
+ // The deterministic provider doesn't support thinking, so the variant
405
+ // is resolved but silently dropped (no matching thinking values).
406
+ // This test verifies the variant cascade code path runs without crashing
407
+ // and the correct model still appears in the footer.
408
+ await setChannelModel({
409
+ channelId: TEXT_CHANNEL_ID,
410
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
411
+ variant: 'high',
412
+ });
413
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
414
+ content: 'Reply with exactly: variant-check',
415
+ });
416
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
417
+ timeout: 4_000,
418
+ predicate: (t) => {
419
+ return t.name === 'Reply with exactly: variant-check';
420
+ },
421
+ });
422
+ await waitForBotMessageContaining({
423
+ discord,
424
+ threadId: thread.id,
425
+ userId: TEST_USER_ID,
426
+ text: '*project',
427
+ timeout: 4_000,
428
+ });
429
+ const messages = await discord.thread(thread.id).getMessages();
430
+ const footerMessage = messages.find((message) => {
431
+ return (message.author.id === discord.botUserId &&
432
+ message.content.startsWith('*'));
433
+ });
434
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
435
+ "--- from: user (agent-model-tester)
436
+ Reply with exactly: variant-check
437
+ --- from: assistant (TestBot)
438
+ ⬥ ok
439
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
440
+ `);
441
+ expect(footerMessage).toBeDefined();
442
+ if (!footerMessage) {
443
+ throw new Error(`Expected footer message but none found. Bot messages: ${messages
444
+ .filter((m) => m.author.id === discord.botUserId)
445
+ .map((m) => m.content.slice(0, 150))
446
+ .join(' | ')}`);
447
+ }
448
+ // Footer should still contain the channel model (variant doesn't crash)
449
+ expect(footerMessage.content).toContain(CHANNEL_MODEL);
450
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL);
451
+ }, 15_000);
452
+ });
@@ -40,7 +40,7 @@ export async function ensureKimakiAudioCategory(guild, botName) {
40
40
  type: ChannelType.GuildCategory,
41
41
  });
42
42
  }
43
- export async function createProjectChannels({ guild, projectDirectory, appId, botName, enableVoiceChannels = false, }) {
43
+ export async function createProjectChannels({ guild, projectDirectory, botName, enableVoiceChannels = false, }) {
44
44
  const baseName = path.basename(projectDirectory);
45
45
  const channelName = `${baseName}`
46
46
  .toLowerCase()
@@ -57,7 +57,6 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
57
57
  channelId: textChannel.id,
58
58
  directory: projectDirectory,
59
59
  channelType: 'text',
60
- appId,
61
60
  });
62
61
  let voiceChannelId = null;
63
62
  if (enableVoiceChannels) {
@@ -71,7 +70,6 @@ export async function createProjectChannels({ guild, projectDirectory, appId, bo
71
70
  channelId: voiceChannel.id,
72
71
  directory: projectDirectory,
73
72
  channelType: 'voice',
74
- appId,
75
73
  });
76
74
  voiceChannelId = voiceChannel.id;
77
75
  }
@@ -94,7 +92,6 @@ export async function getChannelsWithDescriptions(guild) {
94
92
  name: textChannel.name,
95
93
  description,
96
94
  kimakiDirectory: channelConfig?.directory,
97
- kimakiApp: channelConfig?.appId || undefined,
98
95
  });
99
96
  }
100
97
  return channels;
@@ -14,6 +14,10 @@ function createCliForIdParsing() {
14
14
  .command('session search <query>', 'Search sessions')
15
15
  .option('--channel <channelId>', 'Discord channel ID')
16
16
  .option('--project <path>', 'Project path');
17
+ cli
18
+ .command('session export-events-jsonl', 'Export in-memory events to JSONL')
19
+ .option('--session <sessionId>', 'Session ID')
20
+ .option('--out <file>', 'Output path');
17
21
  cli
18
22
  .command('add-project', 'Add a project')
19
23
  .option('-g, --guild <guildId>', 'Discord guild/server ID');
@@ -68,6 +72,27 @@ describe('goke CLI ID parsing', () => {
68
72
  expect(result.options.channel).toBe(channelId);
69
73
  expect(typeof result.options.channel).toBe('string');
70
74
  });
75
+ test('keeps session export options as strings', () => {
76
+ const cli = createCliForIdParsing();
77
+ const sessionId = '001111222233334444';
78
+ const outPath = './tmp/session-events.jsonl';
79
+ const result = cli.parse([
80
+ 'node',
81
+ 'kimaki',
82
+ 'session',
83
+ 'export-events-jsonl',
84
+ '--session',
85
+ sessionId,
86
+ '--out',
87
+ outPath,
88
+ ], {
89
+ run: false,
90
+ });
91
+ expect(result.options.session).toBe(sessionId);
92
+ expect(typeof result.options.session).toBe('string');
93
+ expect(result.options.out).toBe(outPath);
94
+ expect(typeof result.options.out).toBe('string');
95
+ });
71
96
  test('keeps --send-at cron string intact', () => {
72
97
  const cli = createCliForIdParsing();
73
98
  const cron = '0 9 * * 1';