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.
- package/dist/agent-model.e2e.test.js +6 -3
- package/dist/cli-send-thread.e2e.test.js +280 -0
- package/dist/discord-bot.js +9 -0
- package/dist/gateway-proxy.e2e.test.js +5 -3
- package/dist/kimaki-opencode-plugin.js +1 -0
- package/dist/message-finish-field.e2e.test.js +164 -0
- package/dist/opencode.js +54 -35
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +12 -1
- package/dist/queue-advanced-footer.e2e.test.js +6 -6
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -5
- package/dist/queue-advanced-permissions-typing.e2e.test.js +12 -1
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +0 -5
- package/dist/queue-question-select-drain.e2e.test.js +2 -1
- package/dist/runtime-lifecycle.e2e.test.js +4 -5
- package/dist/session-handler/event-stream-state.test.js +3 -0
- package/dist/thread-message-queue.e2e.test.js +3 -1
- package/dist/undo-redo.e2e.test.js +1 -0
- package/package.json +6 -5
- package/src/agent-model.e2e.test.ts +6 -3
- package/src/cli-send-thread.e2e.test.ts +365 -0
- package/src/discord-bot.ts +10 -0
- package/src/gateway-proxy.e2e.test.ts +5 -3
- package/src/kimaki-opencode-plugin.ts +1 -0
- package/src/message-finish-field.e2e.test.ts +191 -0
- package/src/opencode.ts +54 -35
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +12 -1
- package/src/queue-advanced-footer.e2e.test.ts +6 -6
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -5
- package/src/queue-advanced-permissions-typing.e2e.test.ts +12 -1
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +0 -5
- package/src/queue-question-select-drain.e2e.test.ts +2 -1
- package/src/runtime-lifecycle.e2e.test.ts +4 -5
- package/src/session-handler/event-stream-state.test.ts +3 -0
- package/src/thread-message-queue.e2e.test.ts +3 -1
- 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
|
+
});
|
package/dist/discord-bot.js
CHANGED
|
@@ -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
|
-
⬥
|
|
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
|
-
⬥
|
|
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
|
-
|
|
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);
|