kimaki 0.4.87 → 0.4.88

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 (37) hide show
  1. package/dist/agent-model.e2e.test.js +6 -3
  2. package/dist/cli-send-thread.e2e.test.js +280 -0
  3. package/dist/discord-bot.js +9 -0
  4. package/dist/gateway-proxy.e2e.test.js +5 -3
  5. package/dist/kimaki-opencode-plugin.js +1 -0
  6. package/dist/message-finish-field.e2e.test.js +164 -0
  7. package/dist/opencode.js +54 -35
  8. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  9. package/dist/queue-advanced-action-buttons.e2e.test.js +12 -1
  10. package/dist/queue-advanced-footer.e2e.test.js +6 -6
  11. package/dist/queue-advanced-model-switch.e2e.test.js +2 -5
  12. package/dist/queue-advanced-permissions-typing.e2e.test.js +12 -1
  13. package/dist/queue-advanced-typing-interrupt.e2e.test.js +0 -5
  14. package/dist/queue-question-select-drain.e2e.test.js +2 -1
  15. package/dist/runtime-lifecycle.e2e.test.js +4 -5
  16. package/dist/session-handler/event-stream-state.test.js +3 -0
  17. package/dist/thread-message-queue.e2e.test.js +3 -1
  18. package/dist/undo-redo.e2e.test.js +1 -0
  19. package/package.json +6 -5
  20. package/src/agent-model.e2e.test.ts +6 -3
  21. package/src/cli-send-thread.e2e.test.ts +365 -0
  22. package/src/discord-bot.ts +10 -0
  23. package/src/gateway-proxy.e2e.test.ts +5 -3
  24. package/src/kimaki-opencode-plugin.ts +1 -0
  25. package/src/message-finish-field.e2e.test.ts +191 -0
  26. package/src/opencode.ts +54 -35
  27. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  28. package/src/queue-advanced-action-buttons.e2e.test.ts +12 -1
  29. package/src/queue-advanced-footer.e2e.test.ts +6 -6
  30. package/src/queue-advanced-model-switch.e2e.test.ts +2 -5
  31. package/src/queue-advanced-permissions-typing.e2e.test.ts +12 -1
  32. package/src/queue-advanced-typing-interrupt.e2e.test.ts +0 -5
  33. package/src/queue-question-select-drain.e2e.test.ts +2 -1
  34. package/src/runtime-lifecycle.e2e.test.ts +4 -5
  35. package/src/session-handler/event-stream-state.test.ts +3 -0
  36. package/src/thread-message-queue.e2e.test.ts +3 -1
  37. package/src/undo-redo.e2e.test.ts +1 -0
@@ -302,8 +302,7 @@ describe('agent model resolution', () => {
302
302
  Reply with exactly: agent-model-check
303
303
  --- from: assistant (TestBot)
304
304
  ⬥ ok
305
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
306
- ⬥ ok"
305
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
307
306
  `);
308
307
  expect(footerMessage).toBeDefined();
309
308
  if (!footerMessage) {
@@ -346,7 +345,8 @@ describe('agent model resolution', () => {
346
345
  Reply with exactly: system-context-check
347
346
  --- from: assistant (TestBot)
348
347
  ⬥ system-context-ok
349
- *project ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
348
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
349
+ ⬥ ok"
350
350
  `);
351
351
  }, 15_000);
352
352
  test('new thread uses channel model when channel model preference is set', async () => {
@@ -513,6 +513,7 @@ describe('agent model resolution', () => {
513
513
  Reply with exactly: second-thread-msg
514
514
  --- from: assistant (TestBot)
515
515
  ⬥ ok
516
+ ⬥ ok
516
517
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
517
518
  `);
518
519
  const secondMessages = await discord.thread(thread.id).getMessages();
@@ -595,6 +596,7 @@ describe('agent model resolution', () => {
595
596
  Reply with exactly: default-second-msg
596
597
  --- from: assistant (TestBot)
597
598
  ⬥ ok
599
+ ⬥ ok
598
600
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
599
601
  `);
600
602
  const secondMessages = await discord.thread(thread.id).getMessages();
@@ -662,6 +664,7 @@ describe('agent model resolution', () => {
662
664
  Reply with exactly: after-switch-msg
663
665
  --- from: assistant (TestBot)
664
666
  ⬥ ok
667
+ ⬥ ok
665
668
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
666
669
  `);
667
670
  const secondFooter = [...(await discord.thread(thread.id).getMessages())]
@@ -0,0 +1,280 @@
1
+ // E2e test for `kimaki send --channel` flow.
2
+ // Reproduces the race condition where the bot's MessageCreate GuildText handler
3
+ // tries to call startThread() on the same message that the CLI already created
4
+ // a thread for via REST, causing DiscordAPIError[160004].
5
+ //
6
+ // The test simulates the exact flow: bot posts a starter message with a
7
+ // `start: true` embed marker, then creates a thread on that message via REST.
8
+ // The ThreadCreate handler should pick it up and start a session. The
9
+ // MessageCreate handler must NOT try to startThread() on the same message.
10
+ //
11
+ // Uses opencode-deterministic-provider (no real LLM calls).
12
+ // Poll timeouts: 4s max, 100ms interval.
13
+ import fs from 'node:fs';
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, Routes, } 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, } from './database.js';
24
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
25
+ import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
26
+ import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
27
+ import yaml from 'js-yaml';
28
+ const TEST_USER_ID = '200000000000000830';
29
+ const TEXT_CHANNEL_ID = '200000000000000831';
30
+ const BOT_USER_ID = '200000000000000832';
31
+ function createRunDirectories() {
32
+ const root = path.resolve(process.cwd(), 'tmp', 'cli-send-thread-e2e');
33
+ fs.mkdirSync(root, { recursive: true });
34
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
35
+ const projectDirectory = path.join(root, 'project');
36
+ fs.mkdirSync(projectDirectory, { recursive: true });
37
+ return { root, dataDir, projectDirectory };
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
+ function createDeterministicMatchers() {
60
+ const userReplyMatcher = {
61
+ id: 'user-reply',
62
+ priority: 10,
63
+ when: {
64
+ lastMessageRole: 'user',
65
+ latestUserTextIncludes: 'Reply with exactly:',
66
+ },
67
+ then: {
68
+ parts: [
69
+ { type: 'stream-start', warnings: [] },
70
+ { type: 'text-start', id: 'default-reply' },
71
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
72
+ { type: 'text-end', id: 'default-reply' },
73
+ {
74
+ type: 'finish',
75
+ finishReason: 'stop',
76
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
77
+ },
78
+ ],
79
+ partDelaysMs: [0, 100, 0, 0, 0],
80
+ },
81
+ };
82
+ return [userReplyMatcher];
83
+ }
84
+ describe('kimaki send --channel thread creation', () => {
85
+ let directories;
86
+ let discord;
87
+ let botClient;
88
+ let previousDefaultVerbosity = null;
89
+ let testStartTime = Date.now();
90
+ beforeAll(async () => {
91
+ testStartTime = Date.now();
92
+ directories = createRunDirectories();
93
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
94
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
95
+ setDataDir(directories.dataDir);
96
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
97
+ store.setState({ defaultVerbosity: 'tools_and_text' });
98
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
99
+ discord = new DigitalDiscord({
100
+ botUser: { id: BOT_USER_ID },
101
+ guild: {
102
+ name: 'CLI Send E2E Guild',
103
+ // Use bot as guild owner so bot-authored messages pass
104
+ // hasKimakiBotPermission (owner check). This matches production where
105
+ // the bot typically has admin or is the app owner. Without this, the
106
+ // MessageCreate handler drops bot messages before reaching the GuildText
107
+ // path, hiding the race condition we're testing.
108
+ ownerId: BOT_USER_ID,
109
+ },
110
+ channels: [
111
+ {
112
+ id: TEXT_CHANNEL_ID,
113
+ name: 'cli-send-e2e',
114
+ type: ChannelType.GuildText,
115
+ },
116
+ ],
117
+ users: [
118
+ {
119
+ id: TEST_USER_ID,
120
+ username: 'cli-send-tester',
121
+ },
122
+ ],
123
+ dbUrl: `file:${digitalDiscordDbPath}`,
124
+ });
125
+ await discord.start();
126
+ const providerNpm = url
127
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
128
+ .toString();
129
+ const opencodeConfig = buildDeterministicOpencodeConfig({
130
+ providerName: 'deterministic-provider',
131
+ providerNpm,
132
+ model: 'deterministic-v2',
133
+ smallModel: 'deterministic-v2',
134
+ settings: {
135
+ strict: false,
136
+ matchers: createDeterministicMatchers(),
137
+ },
138
+ });
139
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
140
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
141
+ const hranaResult = await startHranaServer({ dbPath });
142
+ if (hranaResult instanceof Error) {
143
+ throw hranaResult;
144
+ }
145
+ process.env['KIMAKI_DB_URL'] = hranaResult;
146
+ await initDatabase();
147
+ await setBotToken(discord.botUserId, discord.botToken);
148
+ await setChannelDirectory({
149
+ channelId: TEXT_CHANNEL_ID,
150
+ directory: directories.projectDirectory,
151
+ channelType: 'text',
152
+ });
153
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
154
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
155
+ await startDiscordBot({
156
+ token: discord.botToken,
157
+ appId: discord.botUserId,
158
+ discordClient: botClient,
159
+ });
160
+ // Pre-warm the opencode server
161
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
162
+ if (warmup instanceof Error) {
163
+ throw warmup;
164
+ }
165
+ }, 60_000);
166
+ afterAll(async () => {
167
+ if (directories) {
168
+ await cleanupTestSessions({
169
+ projectDirectory: directories.projectDirectory,
170
+ testStartTime,
171
+ });
172
+ }
173
+ if (botClient) {
174
+ botClient.destroy();
175
+ }
176
+ await stopOpencodeServer();
177
+ await Promise.all([
178
+ closeDatabase().catch(() => {
179
+ return;
180
+ }),
181
+ stopHranaServer().catch(() => {
182
+ return;
183
+ }),
184
+ discord?.stop().catch(() => {
185
+ return;
186
+ }),
187
+ ]);
188
+ delete process.env['KIMAKI_LOCK_PORT'];
189
+ delete process.env['KIMAKI_DB_URL'];
190
+ if (previousDefaultVerbosity) {
191
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
192
+ }
193
+ if (directories) {
194
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
195
+ }
196
+ }, 10_000);
197
+ test('bot-posted starter message with start marker creates thread without DiscordAPIError[160004]', async () => {
198
+ // Simulate what `kimaki send --channel` does:
199
+ // 1. Bot posts a starter message with `start: true` embed marker
200
+ // 2. Bot creates a thread on that message via REST
201
+ // The ThreadCreate handler should pick it up. The MessageCreate GuildText
202
+ // handler must NOT try to startThread() on the same message (race).
203
+ const prompt = 'Reply with exactly: cli-send-test';
204
+ const embedMarker = {
205
+ start: true,
206
+ username: 'cli-send-tester',
207
+ userId: TEST_USER_ID,
208
+ };
209
+ // Step 1: Bot posts the starter message (same as CLI's sendDiscordMessageWithOptionalAttachment)
210
+ const starterMessage = (await botClient.rest.post(Routes.channelMessages(TEXT_CHANNEL_ID), {
211
+ body: {
212
+ content: prompt,
213
+ embeds: [
214
+ { color: 0x2b2d31, footer: { text: yaml.dump(embedMarker) } },
215
+ ],
216
+ },
217
+ }));
218
+ // Give the bot's MessageCreate handler time to process the starter
219
+ // message. Without the fix, the handler enters the GuildText path and
220
+ // tries to startThread() on this message, which races the CLI's thread
221
+ // creation below. The digital twin enforces Discord's 160004 uniqueness
222
+ // constraint, so the second startThread call fails.
223
+ await new Promise((resolve) => {
224
+ setTimeout(resolve, 200);
225
+ });
226
+ // Verify the MessageCreate handler did NOT create a thread on this
227
+ // message. If the handler ignored the start marker (correct behavior),
228
+ // no thread exists yet and the REST call below succeeds.
229
+ const threadsBeforeCliCreate = await discord
230
+ .channel(TEXT_CHANNEL_ID)
231
+ .getThreads();
232
+ const preExistingThread = threadsBeforeCliCreate.find((t) => {
233
+ return t.name?.includes('cli-send-test');
234
+ });
235
+ // This is the core regression assertion: without the fix in discord-bot.ts
236
+ // (skipping start markers in the GuildText handler), the MessageCreate
237
+ // handler would create a thread here, and the CLI's REST call below would
238
+ // fail with 160004.
239
+ expect(preExistingThread).toBeUndefined();
240
+ // Step 2: Bot creates a thread on the starter message (same as CLI's Routes.threads call)
241
+ const threadData = (await botClient.rest.post(Routes.threads(TEXT_CHANNEL_ID, starterMessage.id), {
242
+ body: {
243
+ name: 'cli-send-test',
244
+ auto_archive_duration: 1440,
245
+ },
246
+ }));
247
+ // Add test user to thread
248
+ await botClient.rest.put(Routes.threadMembers(threadData.id, TEST_USER_ID));
249
+ // Wait for the bot to reply with the ⬥ prefix (proves ThreadCreate
250
+ // handler picked up the starter message and started a session)
251
+ await waitForBotMessageContaining({
252
+ discord,
253
+ threadId: threadData.id,
254
+ userId: discord.botUserId,
255
+ text: '⬥',
256
+ timeout: 4_000,
257
+ });
258
+ // Wait for footer message (proves session completed successfully)
259
+ await waitForFooterMessage({
260
+ discord,
261
+ threadId: threadData.id,
262
+ timeout: 4_000,
263
+ afterMessageIncludes: '⬥',
264
+ afterAuthorId: discord.botUserId,
265
+ });
266
+ // Verify no DiscordAPIError[160004] or other errors in the thread.
267
+ // Before the fix, the MessageCreate GuildText handler would race the
268
+ // CLI's thread creation and produce an error message here.
269
+ const messages = await discord.thread(threadData.id).getMessages();
270
+ const errorMessages = messages.filter((m) => {
271
+ return m.content.includes('Error:') || m.content.includes('160004');
272
+ });
273
+ expect(errorMessages).toHaveLength(0);
274
+ // Verify at least one ⬥ reply exists (session produced output)
275
+ const botReplies = messages.filter((m) => {
276
+ return (m.author.id === discord.botUserId && m.content.startsWith('⬥'));
277
+ });
278
+ expect(botReplies.length).toBeGreaterThanOrEqual(1);
279
+ }, 15_000);
280
+ });
@@ -512,6 +512,15 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
512
512
  }
513
513
  }
514
514
  if (channel.type === ChannelType.GuildText) {
515
+ // `kimaki send` posts a starter message with a `start` embed marker,
516
+ // then creates the thread via REST. The ThreadCreate handler picks up
517
+ // that thread and starts the session. If we don't skip here, this
518
+ // handler races the CLI to call startThread() on the same message,
519
+ // causing DiscordAPIError[160004] "A thread has already been created
520
+ // for this message".
521
+ if (promptMarker?.start) {
522
+ return;
523
+ }
515
524
  const textChannel = channel;
516
525
  voiceLogger.log(`[GUILD_TEXT] Message in text channel #${textChannel.name} (${textChannel.id})`);
517
526
  const channelConfig = await getChannelDirectory(textChannel.id);
@@ -353,8 +353,9 @@ describeIf('gateway-proxy e2e', () => {
353
353
  --- from: user (proxy-tester)
354
354
  follow up through proxy
355
355
  --- from: assistant (TestBot)
356
- gateway-proxy-reply
357
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
356
+ ok
357
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
358
+ ⬥ ok"
358
359
  `);
359
360
  expect(reply).toBeDefined();
360
361
  expect(reply.content.trim().length).toBeGreaterThan(0);
@@ -384,8 +385,9 @@ describeIf('gateway-proxy e2e', () => {
384
385
  --- from: user (proxy-tester)
385
386
  follow up through proxy
386
387
  --- from: assistant (TestBot)
387
- gateway-proxy-reply
388
+ ok
388
389
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
390
+ ⬥ ok
389
391
  --- from: user (proxy-tester)
390
392
  !echo proxy-shell-test
391
393
  --- from: assistant (TestBot)
@@ -13,3 +13,4 @@ export { contextAwarenessPlugin } from './context-awareness-plugin.js';
13
13
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
14
14
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
15
15
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
16
+ export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard';
@@ -0,0 +1,164 @@
1
+ // E2e test verifying that the opencode server populates the `finish` field
2
+ // on assistant messages. This field is critical for kimaki's footer logic:
3
+ // isAssistantMessageNaturalCompletion checks `message.finish !== 'tool-calls'`
4
+ // to suppress footers on intermediate tool-call steps.
5
+ // When `finish` is missing/null, every completed assistant message gets a
6
+ // spurious footer, breaking multi-step tool chains (16 test failures).
7
+ //
8
+ // Direct SDK test — no Discord layer needed since this is a server-level bug.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import url from 'node:url';
12
+ import { test, expect, beforeAll, afterAll } from 'vitest';
13
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
14
+ import { setDataDir } from './config.js';
15
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js';
16
+ import { cleanupTestSessions } from './test-utils.js';
17
+ const ROOT = path.resolve(process.cwd(), 'tmp', 'finish-field-e2e');
18
+ function createRunDirectories() {
19
+ fs.mkdirSync(ROOT, { recursive: true });
20
+ const dataDir = fs.mkdtempSync(path.join(ROOT, 'data-'));
21
+ const projectDirectory = path.join(ROOT, 'project');
22
+ fs.mkdirSync(projectDirectory, { recursive: true });
23
+ return { dataDir, projectDirectory };
24
+ }
25
+ function createMatchers() {
26
+ // Tool-call step: finish="tool-calls"
27
+ const toolCallMatcher = {
28
+ id: 'finish-tool-call',
29
+ priority: 20,
30
+ when: {
31
+ lastMessageRole: 'user',
32
+ latestUserTextIncludes: 'FINISH_FIELD_TOOLCALL',
33
+ },
34
+ then: {
35
+ parts: [
36
+ { type: 'stream-start', warnings: [] },
37
+ { type: 'text-start', id: 'ft' },
38
+ { type: 'text-delta', id: 'ft', delta: 'calling tool' },
39
+ { type: 'text-end', id: 'ft' },
40
+ {
41
+ type: 'tool-call',
42
+ toolCallId: 'finish-bash',
43
+ toolName: 'bash',
44
+ input: JSON.stringify({ command: 'echo ok', description: 'test' }),
45
+ },
46
+ {
47
+ type: 'finish',
48
+ finishReason: 'tool-calls',
49
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
50
+ },
51
+ ],
52
+ },
53
+ };
54
+ // Follow-up after tool result: finish="stop"
55
+ const followupMatcher = {
56
+ id: 'finish-followup',
57
+ priority: 21,
58
+ when: {
59
+ lastMessageRole: 'tool',
60
+ latestUserTextIncludes: 'FINISH_FIELD_TOOLCALL',
61
+ },
62
+ then: {
63
+ parts: [
64
+ { type: 'stream-start', warnings: [] },
65
+ { type: 'text-start', id: 'ff' },
66
+ { type: 'text-delta', id: 'ff', delta: 'tool done' },
67
+ { type: 'text-end', id: 'ff' },
68
+ {
69
+ type: 'finish',
70
+ finishReason: 'stop',
71
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
72
+ },
73
+ ],
74
+ },
75
+ };
76
+ return [toolCallMatcher, followupMatcher];
77
+ }
78
+ let client;
79
+ let directories;
80
+ let testStartTime;
81
+ beforeAll(async () => {
82
+ testStartTime = Date.now();
83
+ directories = createRunDirectories();
84
+ setDataDir(directories.dataDir);
85
+ const providerNpm = url
86
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
87
+ .toString();
88
+ const opencodeConfig = buildDeterministicOpencodeConfig({
89
+ providerName: 'deterministic-provider',
90
+ providerNpm,
91
+ model: 'deterministic-v2',
92
+ smallModel: 'deterministic-v2',
93
+ settings: { strict: false, matchers: createMatchers() },
94
+ });
95
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
96
+ const getClient = await initializeOpencodeForDirectory(directories.projectDirectory);
97
+ if (getClient instanceof Error) {
98
+ throw getClient;
99
+ }
100
+ client = getClient();
101
+ }, 60_000);
102
+ afterAll(async () => {
103
+ await cleanupTestSessions({
104
+ projectDirectory: directories.projectDirectory,
105
+ testStartTime,
106
+ });
107
+ await stopOpencodeServer();
108
+ }, 10_000);
109
+ test('tool-call step has finish="tool-calls", follow-up has finish="stop"', async () => {
110
+ const session = await client.session.create({
111
+ directory: directories.projectDirectory,
112
+ title: 'finish-field-test',
113
+ });
114
+ const sessionID = session.data.id;
115
+ await client.session.promptAsync({
116
+ sessionID,
117
+ directory: directories.projectDirectory,
118
+ parts: [{ type: 'text', text: 'FINISH_FIELD_TOOLCALL' }],
119
+ });
120
+ // Poll until we have 2 completed assistant messages (tool-call + follow-up)
121
+ const maxWait = 8_000;
122
+ const pollStart = Date.now();
123
+ let completedAssistants = [];
124
+ while (Date.now() - pollStart < maxWait) {
125
+ const msgs = await client.session.messages({ sessionID });
126
+ completedAssistants = (msgs.data || [])
127
+ .filter((m) => {
128
+ return m.info.role === 'assistant' && m.info.time.completed;
129
+ })
130
+ .map((m) => {
131
+ return {
132
+ finish: m.info.finish ?? null,
133
+ partTypes: m.parts.map((p) => { return p.type; }),
134
+ };
135
+ });
136
+ if (completedAssistants.length >= 2) {
137
+ break;
138
+ }
139
+ await new Promise((resolve) => { setTimeout(resolve, 100); });
140
+ }
141
+ // Snapshot completed assistant messages — finish should NOT be null
142
+ expect(completedAssistants).toMatchInlineSnapshot(`
143
+ [
144
+ {
145
+ "finish": null,
146
+ "partTypes": [
147
+ "step-start",
148
+ "text",
149
+ "step-finish",
150
+ ],
151
+ },
152
+ {
153
+ "finish": null,
154
+ "partTypes": [
155
+ "step-start",
156
+ "text",
157
+ "step-finish",
158
+ ],
159
+ },
160
+ ]
161
+ `);
162
+ const finishes = completedAssistants.map((m) => { return m.finish; });
163
+ expect(finishes).toEqual(['tool-calls', 'stop']);
164
+ }, 15_000);
package/dist/opencode.js CHANGED
@@ -378,6 +378,59 @@ async function startSingleServer() {
378
378
  XDG_STATE_HOME: path.join(root, '.local', 'state'),
379
379
  };
380
380
  })();
381
+ // Write config to a file instead of passing via OPENCODE_CONFIG_CONTENT env var.
382
+ // OPENCODE_CONFIG (file path) is loaded before project config in opencode's
383
+ // priority chain, so project-level opencode.json can override kimaki defaults.
384
+ // OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
385
+ // causing issue #90 (project permissions not being respected).
386
+ const opencodeConfig = {
387
+ $schema: 'https://opencode.ai/config.json',
388
+ lsp: false,
389
+ formatter: false,
390
+ plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
391
+ permission: {
392
+ edit: 'allow',
393
+ bash: 'allow',
394
+ external_directory: externalDirectoryPermissions,
395
+ webfetch: 'allow',
396
+ },
397
+ agent: {
398
+ explore: {
399
+ permission: {
400
+ '*': 'deny',
401
+ grep: 'allow',
402
+ glob: 'allow',
403
+ list: 'allow',
404
+ read: {
405
+ '*': 'allow',
406
+ '*.env': 'deny',
407
+ '*.env.*': 'deny',
408
+ '*.env.example': 'allow',
409
+ },
410
+ webfetch: 'allow',
411
+ websearch: 'allow',
412
+ codesearch: 'allow',
413
+ external_directory: externalDirectoryPermissions,
414
+ },
415
+ },
416
+ },
417
+ skills: {
418
+ paths: [path.resolve(__dirname, '..', 'skills')],
419
+ },
420
+ };
421
+ const opencodeConfigPath = path.join(getDataDir(), 'opencode-config.json');
422
+ const opencodeConfigJson = JSON.stringify(opencodeConfig, null, 2);
423
+ const existingContent = (() => {
424
+ try {
425
+ return fs.readFileSync(opencodeConfigPath, 'utf-8');
426
+ }
427
+ catch {
428
+ return '';
429
+ }
430
+ })();
431
+ if (existingContent !== opencodeConfigJson) {
432
+ fs.writeFileSync(opencodeConfigPath, opencodeConfigJson);
433
+ }
381
434
  const serverProcess = spawn(spawnCommand, spawnArgs, {
382
435
  stdio: 'pipe',
383
436
  detached: false,
@@ -387,41 +440,7 @@ async function startSingleServer() {
387
440
  cwd: os.homedir(),
388
441
  env: {
389
442
  ...process.env,
390
- OPENCODE_CONFIG_CONTENT: JSON.stringify({
391
- $schema: 'https://opencode.ai/config.json',
392
- lsp: false,
393
- formatter: false,
394
- plugin: [new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href],
395
- permission: {
396
- edit: 'allow',
397
- bash: 'allow',
398
- external_directory: externalDirectoryPermissions,
399
- webfetch: 'allow',
400
- },
401
- agent: {
402
- explore: {
403
- permission: {
404
- '*': 'deny',
405
- grep: 'allow',
406
- glob: 'allow',
407
- list: 'allow',
408
- read: {
409
- '*': 'allow',
410
- '*.env': 'deny',
411
- '*.env.*': 'deny',
412
- '*.env.example': 'allow',
413
- },
414
- webfetch: 'allow',
415
- websearch: 'allow',
416
- codesearch: 'allow',
417
- external_directory: externalDirectoryPermissions,
418
- },
419
- },
420
- },
421
- skills: {
422
- paths: [path.resolve(__dirname, '..', 'skills')],
423
- },
424
- }),
443
+ OPENCODE_CONFIG: opencodeConfigPath,
425
444
  OPENCODE_PORT: port.toString(),
426
445
  KIMAKI: '1',
427
446
  KIMAKI_DATA_DIR: getDataDir(),
@@ -84,6 +84,7 @@ e2eTest('queue advanced: abort and retry', () => {
84
84
  --- from: assistant (TestBot)
85
85
  ⬥ ok
86
86
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
87
+ ⬥ ok
87
88
  --- from: user (queue-advanced-tester)
88
89
  PLUGIN_TIMEOUT_SLEEP_MARKER
89
90
  --- from: assistant (TestBot)
@@ -91,7 +92,6 @@ e2eTest('queue advanced: abort and retry', () => {
91
92
  --- from: user (queue-advanced-tester)
92
93
  Reply with exactly: papa
93
94
  --- from: assistant (TestBot)
94
- ⬥ ok
95
95
  *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
96
96
  `);
97
97
  expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1);