@brianli/kimaki 0.4.73-brianli.3 → 0.4.73-brianli.6
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/cli.js +22 -0
- package/dist/debounced-process-flush.js +77 -0
- package/dist/event-stream-real-capture.e2e.test.js +550 -0
- package/dist/generated/client.js +3 -1
- package/dist/generated/internal/class.js +10 -2
- package/dist/generated/internal/prismaNamespace.js +4 -4
- package/dist/generated/models/session_events.js +1 -0
- package/dist/opencode-interrupt-plugin.js +183 -0
- package/dist/opencode-interrupt-plugin.test.js +263 -0
- package/dist/queue-advanced-abort.e2e.test.js +293 -0
- package/dist/queue-advanced-e2e-setup.js +346 -0
- package/dist/queue-advanced-footer.e2e.test.js +298 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +105 -0
- package/dist/queue-advanced-typing.e2e.test.js +162 -0
- package/dist/session-handler/event-stream-state.js +276 -0
- package/dist/session-handler/event-stream-state.test.js +257 -0
- package/package.json +2 -2
- package/src/cli.ts +27 -0
- package/src/generated/client.ts +3 -1
- package/src/generated/internal/class.ts +17 -5
- package/src/generated/internal/prismaNamespace.ts +4 -4
package/dist/cli.js
CHANGED
|
@@ -874,6 +874,28 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
874
874
|
else if (forceRestartOnboarding && existingBot) {
|
|
875
875
|
note('Ignoring saved credentials due to --restart-onboarding flag', 'Restart Onboarding');
|
|
876
876
|
}
|
|
877
|
+
// 3b. Gateway env vars present — skip all interactive prompts entirely.
|
|
878
|
+
if (KIMAKI_CLIENT_ID && KIMAKI_CLIENT_SECRET && !forceRestartOnboarding) {
|
|
879
|
+
if (!KIMAKI_SHARED_APP_ID) {
|
|
880
|
+
cliLogger.error('Gateway mode is not available yet. KIMAKI_SHARED_APP_ID is not configured.');
|
|
881
|
+
process.exit(EXIT_NO_RESTART);
|
|
882
|
+
}
|
|
883
|
+
cliLogger.log('Using KIMAKI_CLIENT_ID and KIMAKI_CLIENT_SECRET from environment. Skipping onboarding.');
|
|
884
|
+
await setBotMode({
|
|
885
|
+
appId: KIMAKI_SHARED_APP_ID,
|
|
886
|
+
mode: 'gateway',
|
|
887
|
+
clientId: KIMAKI_CLIENT_ID,
|
|
888
|
+
clientSecret: KIMAKI_CLIENT_SECRET,
|
|
889
|
+
proxyUrl: KIMAKI_GATEWAY_PROXY_REST_BASE_URL,
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
appId: KIMAKI_SHARED_APP_ID,
|
|
893
|
+
token: `${KIMAKI_CLIENT_ID}:${KIMAKI_CLIENT_SECRET}`,
|
|
894
|
+
isQuickStart: !addChannels,
|
|
895
|
+
isGatewayMode: true,
|
|
896
|
+
previousAppId: existingBot?.appId,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
877
899
|
// When --gateway is passed, skip the mode selector and go straight to gateway mode.
|
|
878
900
|
const modeChoice = forceGateway
|
|
879
901
|
? 'gateway'
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Debounced async callback with centralized shutdown flushing.
|
|
2
|
+
// Used for persistence paths that should batch writes during runtime
|
|
3
|
+
// while allowing the bot's single SIGTERM/SIGINT handler to flush all callbacks.
|
|
4
|
+
const processFlushCallbacks = new Set();
|
|
5
|
+
export async function flushDebouncedProcessCallbacks() {
|
|
6
|
+
const callbacks = [...processFlushCallbacks];
|
|
7
|
+
await Promise.allSettled(callbacks.map((callback) => {
|
|
8
|
+
return callback();
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
export function createDebouncedProcessFlush({ waitMs, callback, onError, }) {
|
|
12
|
+
let timeout;
|
|
13
|
+
let inFlight;
|
|
14
|
+
let dirty = false;
|
|
15
|
+
async function run() {
|
|
16
|
+
if (!dirty) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (inFlight) {
|
|
20
|
+
await inFlight;
|
|
21
|
+
if (!dirty) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
dirty = false;
|
|
26
|
+
const current = Promise.resolve()
|
|
27
|
+
.then(() => {
|
|
28
|
+
return callback();
|
|
29
|
+
})
|
|
30
|
+
.catch((error) => {
|
|
31
|
+
if (onError) {
|
|
32
|
+
const wrappedError = error instanceof Error
|
|
33
|
+
? error
|
|
34
|
+
: new Error('Debounced process flush failed', { cause: error });
|
|
35
|
+
onError(wrappedError);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
inFlight = current;
|
|
39
|
+
await current;
|
|
40
|
+
if (inFlight === current) {
|
|
41
|
+
inFlight = undefined;
|
|
42
|
+
}
|
|
43
|
+
if (dirty) {
|
|
44
|
+
await run();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function trigger() {
|
|
48
|
+
dirty = true;
|
|
49
|
+
if (timeout) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
timeout = setTimeout(() => {
|
|
53
|
+
timeout = undefined;
|
|
54
|
+
void run();
|
|
55
|
+
}, waitMs);
|
|
56
|
+
}
|
|
57
|
+
async function flush() {
|
|
58
|
+
if (timeout) {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
timeout = undefined;
|
|
61
|
+
}
|
|
62
|
+
await run();
|
|
63
|
+
}
|
|
64
|
+
const processFlushCallback = async () => {
|
|
65
|
+
await flush();
|
|
66
|
+
};
|
|
67
|
+
processFlushCallbacks.add(processFlushCallback);
|
|
68
|
+
async function dispose() {
|
|
69
|
+
processFlushCallbacks.delete(processFlushCallback);
|
|
70
|
+
await flush();
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
trigger,
|
|
74
|
+
flush,
|
|
75
|
+
dispose,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
// E2e capture tests for generating real OpenCode session-event JSONL fixtures.
|
|
2
|
+
// Uses opencode-cached-provider + Gemini to record real tool/lifecycle streams
|
|
3
|
+
// (task, interruption, permission, action buttons, and question flows).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
|
|
8
|
+
import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
|
|
9
|
+
import { DigitalDiscord } from 'discord-digital-twin/src';
|
|
10
|
+
import { CachedOpencodeProviderProxy } from 'opencode-cached-provider';
|
|
11
|
+
import { setDataDir } from './config.js';
|
|
12
|
+
import { store } from './store.js';
|
|
13
|
+
import { startDiscordBot } from './discord-bot.js';
|
|
14
|
+
import { closeDatabase, getChannelVerbosity, initDatabase, setBotToken, setChannelDirectory, setChannelVerbosity, } from './database.js';
|
|
15
|
+
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
16
|
+
import { cleanupOpencodeServers, cleanupTestSessions } from './test-utils.js';
|
|
17
|
+
import { waitForBotMessageContaining, waitForBotReplyAfterUserMessage } from './test-utils.js';
|
|
18
|
+
import { disposeRuntime, pendingPermissions } from './session-handler/thread-session-runtime.js';
|
|
19
|
+
import { pendingActionButtonContexts } from './commands/action-buttons.js';
|
|
20
|
+
import { pendingQuestionContexts } from './commands/ask-question.js';
|
|
21
|
+
const geminiApiKey = process.env['GEMINI_API_KEY'] ||
|
|
22
|
+
process.env['GOOGLE_GENERATIVE_AI_API_KEY'] ||
|
|
23
|
+
'';
|
|
24
|
+
const geminiModel = process.env['GEMINI_FLASH_MODEL'] || 'gemini-2.5-flash';
|
|
25
|
+
const shouldRunRealCapture = geminiApiKey.length > 0 && process.env['KIMAKI_RUN_REAL_EVENT_CAPTURE'] === '1';
|
|
26
|
+
const realCaptureTest = shouldRunRealCapture ? test : test.skip;
|
|
27
|
+
const TEST_USER_ID = '200000000000003001';
|
|
28
|
+
const TEXT_CHANNEL_ID = '200000000000003002';
|
|
29
|
+
function createRunDirectories() {
|
|
30
|
+
const root = path.resolve(process.cwd(), 'tmp', 'event-stream-real-capture-e2e');
|
|
31
|
+
fs.mkdirSync(root, { recursive: true });
|
|
32
|
+
const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
|
|
33
|
+
const projectDirectory = path.join(root, 'project');
|
|
34
|
+
const providerCacheDbPath = path.join(root, 'provider-cache.db');
|
|
35
|
+
const sessionEventsDir = path.join(root, 'opencode-session-events');
|
|
36
|
+
const fixtureOutputDir = path.resolve(process.cwd(), 'src', 'session-handler', 'event-stream-fixtures');
|
|
37
|
+
fs.mkdirSync(projectDirectory, { recursive: true });
|
|
38
|
+
fs.mkdirSync(sessionEventsDir, { recursive: true });
|
|
39
|
+
return {
|
|
40
|
+
root,
|
|
41
|
+
dataDir,
|
|
42
|
+
projectDirectory,
|
|
43
|
+
providerCacheDbPath,
|
|
44
|
+
sessionEventsDir,
|
|
45
|
+
fixtureOutputDir,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function chooseLockPort() {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const server = net.createServer();
|
|
51
|
+
server.listen(0, () => {
|
|
52
|
+
const address = server.address();
|
|
53
|
+
if (!address || typeof address === 'string') {
|
|
54
|
+
server.close();
|
|
55
|
+
reject(new Error('Failed to resolve lock port'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const port = address.port;
|
|
59
|
+
server.close(() => {
|
|
60
|
+
resolve(port);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function createDiscordJsClient({ restUrl }) {
|
|
66
|
+
return new Client({
|
|
67
|
+
intents: [
|
|
68
|
+
GatewayIntentBits.Guilds,
|
|
69
|
+
GatewayIntentBits.GuildMessages,
|
|
70
|
+
GatewayIntentBits.MessageContent,
|
|
71
|
+
GatewayIntentBits.GuildVoiceStates,
|
|
72
|
+
],
|
|
73
|
+
partials: [
|
|
74
|
+
Partials.Channel,
|
|
75
|
+
Partials.Message,
|
|
76
|
+
Partials.User,
|
|
77
|
+
Partials.ThreadMember,
|
|
78
|
+
],
|
|
79
|
+
rest: {
|
|
80
|
+
api: restUrl,
|
|
81
|
+
version: '10',
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function readJsonlEvents(filePath) {
|
|
86
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
87
|
+
const lines = content.split('\n').filter((line) => {
|
|
88
|
+
return line.trim().length > 0;
|
|
89
|
+
});
|
|
90
|
+
return lines.map((line) => {
|
|
91
|
+
return JSON.parse(line);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function hasToolEvent({ events, tool }) {
|
|
95
|
+
return events.some((line) => {
|
|
96
|
+
if (line.event.type !== 'message.part.updated') {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const part = line.event.properties.part;
|
|
100
|
+
if (part.type !== 'tool') {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return part.tool === tool;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function listJsonlFiles(directory) {
|
|
107
|
+
return fs.readdirSync(directory).filter((name) => {
|
|
108
|
+
return name.endsWith('.jsonl');
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
async function waitForNewOrUpdatedSessionLog({ directory, before, timeoutMs, }) {
|
|
112
|
+
const start = Date.now();
|
|
113
|
+
while (Date.now() - start < timeoutMs) {
|
|
114
|
+
const files = listJsonlFiles(directory);
|
|
115
|
+
const changedFiles = files.filter((fileName) => {
|
|
116
|
+
const filePath = path.join(directory, fileName);
|
|
117
|
+
const stat = fs.statSync(filePath);
|
|
118
|
+
const previous = before.get(fileName);
|
|
119
|
+
if (!previous) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return stat.size > previous.size || stat.mtimeMs > previous.mtimeMs;
|
|
123
|
+
});
|
|
124
|
+
if (changedFiles.length > 0) {
|
|
125
|
+
const newest = [...changedFiles].sort((a, b) => {
|
|
126
|
+
const aMtime = fs.statSync(path.join(directory, a)).mtimeMs;
|
|
127
|
+
const bMtime = fs.statSync(path.join(directory, b)).mtimeMs;
|
|
128
|
+
return bMtime - aMtime;
|
|
129
|
+
})[0];
|
|
130
|
+
if (newest) {
|
|
131
|
+
return path.join(directory, newest);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await new Promise((resolve) => {
|
|
135
|
+
setTimeout(resolve, 200);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
throw new Error('Timed out waiting for changed session event log file');
|
|
139
|
+
}
|
|
140
|
+
async function waitForPendingPermission({ threadId, timeoutMs, }) {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
while (Date.now() - start < timeoutMs) {
|
|
143
|
+
const perms = pendingPermissions.get(threadId);
|
|
144
|
+
const first = perms ? [...perms.values()][0] : undefined;
|
|
145
|
+
if (first?.contextHash && first.messageId) {
|
|
146
|
+
return { contextHash: first.contextHash, messageId: first.messageId };
|
|
147
|
+
}
|
|
148
|
+
await new Promise((resolve) => {
|
|
149
|
+
setTimeout(resolve, 100);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
throw new Error('Timed out waiting for pending permission context');
|
|
153
|
+
}
|
|
154
|
+
async function waitForPendingActionButtons({ threadId, timeoutMs, }) {
|
|
155
|
+
const start = Date.now();
|
|
156
|
+
while (Date.now() - start < timeoutMs) {
|
|
157
|
+
const entry = [...pendingActionButtonContexts.entries()].find(([, context]) => {
|
|
158
|
+
return context.thread.id === threadId && !context.resolved && Boolean(context.messageId);
|
|
159
|
+
});
|
|
160
|
+
if (entry && entry[1].messageId) {
|
|
161
|
+
return { contextHash: entry[0], messageId: entry[1].messageId };
|
|
162
|
+
}
|
|
163
|
+
await new Promise((resolve) => {
|
|
164
|
+
setTimeout(resolve, 100);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
throw new Error('Timed out waiting for pending action buttons context');
|
|
168
|
+
}
|
|
169
|
+
async function waitForPendingQuestion({ discord, threadId, timeoutMs, }) {
|
|
170
|
+
const start = Date.now();
|
|
171
|
+
while (Date.now() - start < timeoutMs) {
|
|
172
|
+
const entry = [...pendingQuestionContexts.entries()].find(([, context]) => {
|
|
173
|
+
return context.thread.id === threadId;
|
|
174
|
+
});
|
|
175
|
+
if (entry) {
|
|
176
|
+
const [contextHash, context] = entry;
|
|
177
|
+
const questionMessage = await discord.thread(threadId).waitForMessage({
|
|
178
|
+
timeout: 10_000,
|
|
179
|
+
predicate: (message) => {
|
|
180
|
+
return message.author.id === discord.botUserId
|
|
181
|
+
&& message.content.includes('Choose one option');
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
if (questionMessage) {
|
|
185
|
+
return {
|
|
186
|
+
contextHash,
|
|
187
|
+
questionMessage,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
await new Promise((resolve) => {
|
|
192
|
+
setTimeout(resolve, 100);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
throw new Error('Timed out waiting for pending question context');
|
|
196
|
+
}
|
|
197
|
+
describe('real event stream capture fixtures (cached provider)', () => {
|
|
198
|
+
const directories = createRunDirectories();
|
|
199
|
+
let lockPort = 0;
|
|
200
|
+
let previousDefaultVerbosity = null;
|
|
201
|
+
let testStartTime = Date.now();
|
|
202
|
+
let botClient = null;
|
|
203
|
+
const proxy = new CachedOpencodeProviderProxy({
|
|
204
|
+
cacheDbPath: directories.providerCacheDbPath,
|
|
205
|
+
targetBaseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
|
206
|
+
apiKey: geminiApiKey,
|
|
207
|
+
cacheMethods: ['POST'],
|
|
208
|
+
});
|
|
209
|
+
const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
|
|
210
|
+
const discord = new DigitalDiscord({
|
|
211
|
+
guild: {
|
|
212
|
+
name: 'Real Event Capture Guild',
|
|
213
|
+
ownerId: TEST_USER_ID,
|
|
214
|
+
},
|
|
215
|
+
channels: [
|
|
216
|
+
{
|
|
217
|
+
id: TEXT_CHANNEL_ID,
|
|
218
|
+
name: 'real-event-capture',
|
|
219
|
+
type: ChannelType.GuildText,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
users: [
|
|
223
|
+
{
|
|
224
|
+
id: TEST_USER_ID,
|
|
225
|
+
username: 'real-capture-user',
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
dbUrl: `file:${digitalDiscordDbPath}`,
|
|
229
|
+
});
|
|
230
|
+
async function captureFixture({ fixtureName, beforeFiles, assertEvents, }) {
|
|
231
|
+
const newLogPath = await waitForNewOrUpdatedSessionLog({
|
|
232
|
+
directory: directories.sessionEventsDir,
|
|
233
|
+
before: beforeFiles,
|
|
234
|
+
timeoutMs: 120_000,
|
|
235
|
+
});
|
|
236
|
+
const fixturePath = path.join(directories.fixtureOutputDir, fixtureName);
|
|
237
|
+
fs.copyFileSync(newLogPath, fixturePath);
|
|
238
|
+
const events = readJsonlEvents(fixturePath);
|
|
239
|
+
assertEvents(events);
|
|
240
|
+
}
|
|
241
|
+
function getSessionLogState() {
|
|
242
|
+
const files = listJsonlFiles(directories.sessionEventsDir);
|
|
243
|
+
return new Map(files.map((fileName) => {
|
|
244
|
+
const stat = fs.statSync(path.join(directories.sessionEventsDir, fileName));
|
|
245
|
+
return [fileName, { size: stat.size, mtimeMs: stat.mtimeMs }];
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
beforeAll(async () => {
|
|
249
|
+
testStartTime = Date.now();
|
|
250
|
+
lockPort = await chooseLockPort();
|
|
251
|
+
listJsonlFiles(directories.sessionEventsDir).forEach((fileName) => {
|
|
252
|
+
fs.rmSync(path.join(directories.sessionEventsDir, fileName), {
|
|
253
|
+
force: true,
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
|
|
257
|
+
process.env['KIMAKI_LOG_OPENCODE_SESSION_EVENTS'] = '1';
|
|
258
|
+
process.env['KIMAKI_OPENCODE_SESSION_EVENTS_DIR'] = directories.sessionEventsDir;
|
|
259
|
+
setDataDir(directories.dataDir);
|
|
260
|
+
previousDefaultVerbosity = store.getState().defaultVerbosity;
|
|
261
|
+
store.setState({ defaultVerbosity: 'tools-and-text' });
|
|
262
|
+
await Promise.all([proxy.start(), discord.start()]);
|
|
263
|
+
const opencodeConfig = proxy.buildOpencodeConfig({
|
|
264
|
+
providerName: 'cached-google-real-events',
|
|
265
|
+
providerNpm: '@ai-sdk/google',
|
|
266
|
+
model: geminiModel,
|
|
267
|
+
smallModel: geminiModel,
|
|
268
|
+
});
|
|
269
|
+
fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
|
|
270
|
+
const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
|
|
271
|
+
const hranaResult = await startHranaServer({ dbPath });
|
|
272
|
+
if (hranaResult instanceof Error) {
|
|
273
|
+
throw hranaResult;
|
|
274
|
+
}
|
|
275
|
+
process.env['KIMAKI_DB_URL'] = hranaResult;
|
|
276
|
+
await initDatabase();
|
|
277
|
+
await setBotToken(discord.botUserId, discord.botToken);
|
|
278
|
+
await setChannelDirectory({
|
|
279
|
+
channelId: TEXT_CHANNEL_ID,
|
|
280
|
+
directory: directories.projectDirectory,
|
|
281
|
+
channelType: 'text',
|
|
282
|
+
appId: discord.botUserId,
|
|
283
|
+
});
|
|
284
|
+
await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools-and-text');
|
|
285
|
+
expect(await getChannelVerbosity(TEXT_CHANNEL_ID)).toBe('tools-and-text');
|
|
286
|
+
botClient = createDiscordJsClient({ restUrl: discord.restUrl });
|
|
287
|
+
await startDiscordBot({
|
|
288
|
+
token: discord.botToken,
|
|
289
|
+
appId: discord.botUserId,
|
|
290
|
+
discordClient: botClient,
|
|
291
|
+
});
|
|
292
|
+
}, 180_000);
|
|
293
|
+
afterEach(async () => {
|
|
294
|
+
[...pendingActionButtonContexts.values()].forEach((context) => {
|
|
295
|
+
clearTimeout(context.timer);
|
|
296
|
+
});
|
|
297
|
+
pendingActionButtonContexts.clear();
|
|
298
|
+
pendingQuestionContexts.clear();
|
|
299
|
+
pendingPermissions.clear();
|
|
300
|
+
const threadIds = [...store.getState().threads.keys()];
|
|
301
|
+
threadIds.forEach((threadId) => {
|
|
302
|
+
disposeRuntime(threadId);
|
|
303
|
+
});
|
|
304
|
+
await cleanupTestSessions({
|
|
305
|
+
projectDirectory: directories.projectDirectory,
|
|
306
|
+
testStartTime,
|
|
307
|
+
});
|
|
308
|
+
}, 180_000);
|
|
309
|
+
afterAll(async () => {
|
|
310
|
+
await cleanupTestSessions({
|
|
311
|
+
projectDirectory: directories.projectDirectory,
|
|
312
|
+
testStartTime,
|
|
313
|
+
});
|
|
314
|
+
if (botClient) {
|
|
315
|
+
botClient.destroy();
|
|
316
|
+
}
|
|
317
|
+
await cleanupOpencodeServers();
|
|
318
|
+
await Promise.all([
|
|
319
|
+
closeDatabase().catch(() => {
|
|
320
|
+
return;
|
|
321
|
+
}),
|
|
322
|
+
stopHranaServer().catch(() => {
|
|
323
|
+
return;
|
|
324
|
+
}),
|
|
325
|
+
proxy.stop().catch(() => {
|
|
326
|
+
return;
|
|
327
|
+
}),
|
|
328
|
+
discord.stop().catch(() => {
|
|
329
|
+
return;
|
|
330
|
+
}),
|
|
331
|
+
]);
|
|
332
|
+
delete process.env['KIMAKI_LOCK_PORT'];
|
|
333
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
334
|
+
delete process.env['KIMAKI_LOG_OPENCODE_SESSION_EVENTS'];
|
|
335
|
+
delete process.env['KIMAKI_OPENCODE_SESSION_EVENTS_DIR'];
|
|
336
|
+
if (previousDefaultVerbosity) {
|
|
337
|
+
store.setState({ defaultVerbosity: previousDefaultVerbosity });
|
|
338
|
+
}
|
|
339
|
+
fs.rmSync(directories.dataDir, { recursive: true, force: true });
|
|
340
|
+
}, 180_000);
|
|
341
|
+
realCaptureTest('capture real task flow fixture', async () => {
|
|
342
|
+
const beforeFiles = getSessionLogState();
|
|
343
|
+
const prompt = 'REAL_FIXTURE_TASK_NORMAL. First response MUST be exactly one tool call: tool `task` with {"description":"inspect repository","subagent_type":"general","prompt":"Read this repository and return exactly: task-subagent-done"}. Do not answer with plain text before the tool call. After the task result returns, respond with exactly: task-normal-done.';
|
|
344
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
345
|
+
content: prompt,
|
|
346
|
+
});
|
|
347
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
348
|
+
timeout: 120_000,
|
|
349
|
+
predicate: (t) => {
|
|
350
|
+
return t.name?.includes('REAL_FIXTURE_TASK_NORMAL') ?? false;
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
await waitForBotMessageContaining({
|
|
354
|
+
discord,
|
|
355
|
+
threadId: thread.id,
|
|
356
|
+
userId: TEST_USER_ID,
|
|
357
|
+
text: '┣ task',
|
|
358
|
+
timeout: 300_000,
|
|
359
|
+
});
|
|
360
|
+
await waitForBotMessageContaining({
|
|
361
|
+
discord,
|
|
362
|
+
threadId: thread.id,
|
|
363
|
+
userId: TEST_USER_ID,
|
|
364
|
+
text: 'task-normal-done',
|
|
365
|
+
timeout: 300_000,
|
|
366
|
+
});
|
|
367
|
+
await captureFixture({
|
|
368
|
+
fixtureName: 'real-session-task-normal.jsonl',
|
|
369
|
+
beforeFiles,
|
|
370
|
+
assertEvents: (events) => {
|
|
371
|
+
expect(events.length).toBeGreaterThan(0);
|
|
372
|
+
expect(hasToolEvent({ events, tool: 'task' })).toBe(true);
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}, 900_000);
|
|
376
|
+
realCaptureTest('capture real task interruption fixture', async () => {
|
|
377
|
+
const beforeFiles = getSessionLogState();
|
|
378
|
+
const setupPrompt = 'REAL_FIXTURE_TASK_INTERRUPT_START. First response MUST call tool `task` with {"description":"long analysis","subagent_type":"general","prompt":"Perform a long analysis over many files and produce extensive notes"}. Do not send plain text before the tool call.';
|
|
379
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
380
|
+
content: setupPrompt,
|
|
381
|
+
});
|
|
382
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
383
|
+
timeout: 120_000,
|
|
384
|
+
predicate: (t) => {
|
|
385
|
+
return t.name?.includes('REAL_FIXTURE_TASK_INTERRUPT_START') ?? false;
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
await waitForBotMessageContaining({
|
|
389
|
+
discord,
|
|
390
|
+
threadId: thread.id,
|
|
391
|
+
userId: TEST_USER_ID,
|
|
392
|
+
text: '┣ task',
|
|
393
|
+
timeout: 300_000,
|
|
394
|
+
});
|
|
395
|
+
await discord.thread(thread.id).user(TEST_USER_ID).sendMessage({
|
|
396
|
+
content: 'REAL_FIXTURE_TASK_INTERRUPT_FOLLOWUP. Stop and reply with exactly: task-interrupt-done.',
|
|
397
|
+
});
|
|
398
|
+
await waitForBotReplyAfterUserMessage({
|
|
399
|
+
discord,
|
|
400
|
+
threadId: thread.id,
|
|
401
|
+
userId: TEST_USER_ID,
|
|
402
|
+
userMessageIncludes: 'REAL_FIXTURE_TASK_INTERRUPT_FOLLOWUP',
|
|
403
|
+
timeout: 300_000,
|
|
404
|
+
});
|
|
405
|
+
await captureFixture({
|
|
406
|
+
fixtureName: 'real-session-task-user-interruption.jsonl',
|
|
407
|
+
beforeFiles,
|
|
408
|
+
assertEvents: (events) => {
|
|
409
|
+
expect(events.length).toBeGreaterThan(0);
|
|
410
|
+
expect(hasToolEvent({ events, tool: 'task' })).toBe(true);
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}, 900_000);
|
|
414
|
+
realCaptureTest('capture real permission fixture for external path access', async () => {
|
|
415
|
+
const beforeFiles = getSessionLogState();
|
|
416
|
+
const prompt = 'REAL_FIXTURE_PERMISSION_EXTERNAL. Use bash (hasSideEffect false) to read this file outside the workspace: /Users/morse/.zprofile. Then summarize the first line.';
|
|
417
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
418
|
+
content: prompt,
|
|
419
|
+
});
|
|
420
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
421
|
+
timeout: 120_000,
|
|
422
|
+
predicate: (t) => {
|
|
423
|
+
return t.name?.includes('REAL_FIXTURE_PERMISSION_EXTERNAL') ?? false;
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
const pending = await waitForPendingPermission({
|
|
427
|
+
threadId: thread.id,
|
|
428
|
+
timeoutMs: 300_000,
|
|
429
|
+
});
|
|
430
|
+
const interaction = await discord.thread(thread.id).user(TEST_USER_ID).clickButton({
|
|
431
|
+
messageId: pending.messageId,
|
|
432
|
+
customId: `permission_once:${pending.contextHash}`,
|
|
433
|
+
});
|
|
434
|
+
await discord.thread(thread.id).waitForInteractionAck({
|
|
435
|
+
interactionId: interaction.id,
|
|
436
|
+
timeout: 30_000,
|
|
437
|
+
});
|
|
438
|
+
await discord.thread(thread.id).waitForBotReply({ timeout: 300_000 });
|
|
439
|
+
await captureFixture({
|
|
440
|
+
fixtureName: 'real-session-permission-external-file.jsonl',
|
|
441
|
+
beforeFiles,
|
|
442
|
+
assertEvents: (events) => {
|
|
443
|
+
const hasPermissionAsked = events.some((line) => {
|
|
444
|
+
return line.event.type === 'permission.asked';
|
|
445
|
+
});
|
|
446
|
+
const hasPermissionReplied = events.some((line) => {
|
|
447
|
+
return line.event.type === 'permission.replied';
|
|
448
|
+
});
|
|
449
|
+
expect(hasPermissionAsked).toBe(true);
|
|
450
|
+
expect(hasPermissionReplied).toBe(true);
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
}, 900_000);
|
|
454
|
+
realCaptureTest('capture real action buttons fixture', async () => {
|
|
455
|
+
const beforeFiles = getSessionLogState();
|
|
456
|
+
const prompt = 'REAL_FIXTURE_ACTION_BUTTONS. First response MUST call tool `kimaki_action_buttons` with {"buttons":[{"label":"Approve capture","color":"green"}]}. Do not send text before the tool call. After user clicks, reply exactly: action-buttons-done.';
|
|
457
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
458
|
+
content: prompt,
|
|
459
|
+
});
|
|
460
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
461
|
+
timeout: 120_000,
|
|
462
|
+
predicate: (t) => {
|
|
463
|
+
return t.name?.includes('REAL_FIXTURE_ACTION_BUTTONS') ?? false;
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
const action = await waitForPendingActionButtons({
|
|
467
|
+
threadId: thread.id,
|
|
468
|
+
timeoutMs: 300_000,
|
|
469
|
+
});
|
|
470
|
+
await waitForBotMessageContaining({
|
|
471
|
+
discord,
|
|
472
|
+
threadId: thread.id,
|
|
473
|
+
userId: TEST_USER_ID,
|
|
474
|
+
text: 'Action Required',
|
|
475
|
+
timeout: 300_000,
|
|
476
|
+
});
|
|
477
|
+
const interaction = await discord.thread(thread.id).user(TEST_USER_ID).clickButton({
|
|
478
|
+
messageId: action.messageId,
|
|
479
|
+
customId: `action_button:${action.contextHash}:0`,
|
|
480
|
+
});
|
|
481
|
+
await discord.thread(thread.id).waitForInteractionAck({
|
|
482
|
+
interactionId: interaction.id,
|
|
483
|
+
timeout: 30_000,
|
|
484
|
+
});
|
|
485
|
+
await waitForBotMessageContaining({
|
|
486
|
+
discord,
|
|
487
|
+
threadId: thread.id,
|
|
488
|
+
userId: TEST_USER_ID,
|
|
489
|
+
text: 'action-buttons-done',
|
|
490
|
+
timeout: 300_000,
|
|
491
|
+
});
|
|
492
|
+
await captureFixture({
|
|
493
|
+
fixtureName: 'real-session-action-buttons.jsonl',
|
|
494
|
+
beforeFiles,
|
|
495
|
+
assertEvents: (events) => {
|
|
496
|
+
expect(events.length).toBeGreaterThan(0);
|
|
497
|
+
const hasActionTool = hasToolEvent({ events, tool: 'kimaki_action_buttons' });
|
|
498
|
+
expect(hasActionTool).toBe(true);
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}, 900_000);
|
|
502
|
+
realCaptureTest('capture real question tool fixture', async () => {
|
|
503
|
+
const beforeFiles = getSessionLogState();
|
|
504
|
+
const prompt = 'REAL_FIXTURE_QUESTION_TOOL. First response MUST call tool `question` with {"questions":[{"question":"Choose one option","header":"Pick one","options":[{"label":"Alpha","description":"Alpha option"},{"label":"Beta","description":"Beta option"}]}]}. Do not send text before the tool call. After user selects, reply exactly: question-tool-done.';
|
|
505
|
+
await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
506
|
+
content: prompt,
|
|
507
|
+
});
|
|
508
|
+
const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
509
|
+
timeout: 120_000,
|
|
510
|
+
predicate: (t) => {
|
|
511
|
+
return t.name?.includes('REAL_FIXTURE_QUESTION_TOOL') ?? false;
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
const pending = await waitForPendingQuestion({
|
|
515
|
+
discord,
|
|
516
|
+
threadId: thread.id,
|
|
517
|
+
timeoutMs: 300_000,
|
|
518
|
+
});
|
|
519
|
+
const interaction = await discord.thread(thread.id).user(TEST_USER_ID).selectMenu({
|
|
520
|
+
messageId: pending.questionMessage.id,
|
|
521
|
+
customId: `ask_question:${pending.contextHash}:0`,
|
|
522
|
+
values: ['0'],
|
|
523
|
+
});
|
|
524
|
+
await discord.thread(thread.id).waitForInteractionAck({
|
|
525
|
+
interactionId: interaction.id,
|
|
526
|
+
timeout: 30_000,
|
|
527
|
+
});
|
|
528
|
+
await waitForBotMessageContaining({
|
|
529
|
+
discord,
|
|
530
|
+
threadId: thread.id,
|
|
531
|
+
userId: TEST_USER_ID,
|
|
532
|
+
text: 'question-tool-done',
|
|
533
|
+
timeout: 300_000,
|
|
534
|
+
});
|
|
535
|
+
await captureFixture({
|
|
536
|
+
fixtureName: 'real-session-question-tool.jsonl',
|
|
537
|
+
beforeFiles,
|
|
538
|
+
assertEvents: (events) => {
|
|
539
|
+
const hasQuestionAsked = events.some((line) => {
|
|
540
|
+
return line.event.type === 'question.asked';
|
|
541
|
+
});
|
|
542
|
+
const hasQuestionReplied = events.some((line) => {
|
|
543
|
+
return line.event.type === 'question.replied';
|
|
544
|
+
});
|
|
545
|
+
expect(hasQuestionAsked).toBe(true);
|
|
546
|
+
expect(hasQuestionReplied).toBe(true);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}, 900_000);
|
|
550
|
+
});
|
package/dist/generated/client.js
CHANGED
|
@@ -24,7 +24,9 @@ export * from "./enums.js";
|
|
|
24
24
|
* Type-safe database client for TypeScript
|
|
25
25
|
* @example
|
|
26
26
|
* ```
|
|
27
|
-
* const prisma = new PrismaClient(
|
|
27
|
+
* const prisma = new PrismaClient({
|
|
28
|
+
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
|
29
|
+
* })
|
|
28
30
|
* // Fetch zero or more Thread_sessions
|
|
29
31
|
* const thread_sessions = await prisma.thread_sessions.findMany()
|
|
30
32
|
* ```
|