kimaki 0.4.75 → 0.4.76
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 +59 -31
- package/dist/commands/ask-question.js +30 -17
- package/dist/discord-bot.js +9 -5
- package/dist/eventsource-parser.test.js +327 -0
- package/dist/message-preprocessing.js +4 -2
- package/dist/queue-advanced-e2e-setup.js +35 -0
- package/dist/queue-advanced-question.e2e.test.js +120 -0
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +109 -0
- package/dist/queue-advanced-typing.e2e.test.js +0 -94
- package/dist/session-handler/thread-session-runtime.js +1 -1
- package/dist/thread-message-queue.e2e.test.js +35 -4
- package/package.json +4 -3
- package/src/cli.ts +84 -34
- package/src/commands/ask-question.ts +36 -18
- package/src/discord-bot.ts +10 -8
- package/src/eventsource-parser.test.ts +351 -0
- package/src/message-preprocessing.ts +4 -2
- package/src/queue-advanced-e2e-setup.ts +36 -0
- package/src/queue-advanced-question.e2e.test.ts +158 -0
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +138 -0
- package/src/queue-advanced-typing.e2e.test.ts +0 -112
- package/src/session-handler/thread-session-runtime.ts +1 -1
- package/src/thread-message-queue.e2e.test.ts +37 -4
package/dist/cli.js
CHANGED
|
@@ -192,10 +192,11 @@ function exitNonInteractiveSetup() {
|
|
|
192
192
|
}
|
|
193
193
|
// Emit a structured JSON line on stdout for non-TTY consumers (cloud sandboxes, CI).
|
|
194
194
|
// Each line is a self-contained JSON object with a "type" field for easy parsing.
|
|
195
|
-
//
|
|
196
|
-
//
|
|
195
|
+
// Lines are prefixed with "data: " and terminated with "\n\n" (SSE format) so consumers
|
|
196
|
+
// can use the eventsource-parser npm package to robustly extract JSON events from noisy
|
|
197
|
+
// process output (other log lines, warnings, etc. are ignored by the parser).
|
|
197
198
|
function emitJsonEvent(event) {
|
|
198
|
-
process.stdout.write(JSON.stringify(event)
|
|
199
|
+
process.stdout.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
199
200
|
}
|
|
200
201
|
async function resolveGatewayInstallCredentials() {
|
|
201
202
|
if (!KIMAKI_GATEWAY_APP_ID) {
|
|
@@ -958,6 +959,42 @@ function showReadyMessage({ kimakiChannels, createdChannels, }) {
|
|
|
958
959
|
}
|
|
959
960
|
note('Leave this process running to keep the bot active.\n\nIf you close this process or restart your machine, run `npx kimaki` again to start the bot.', '⚠️ Keep Running');
|
|
960
961
|
}
|
|
962
|
+
/**
|
|
963
|
+
* Create the default kimaki channel in each guild and send a welcome message.
|
|
964
|
+
* Idempotent: skips guilds that already have the channel.
|
|
965
|
+
* Extracted so both the interactive and headless startup paths share the same logic.
|
|
966
|
+
*/
|
|
967
|
+
async function ensureDefaultChannelsWithWelcome({ guilds, discordClient, appId, isGatewayMode, installerDiscordUserId, }) {
|
|
968
|
+
const created = [];
|
|
969
|
+
for (const guild of guilds) {
|
|
970
|
+
try {
|
|
971
|
+
const result = await createDefaultKimakiChannel({
|
|
972
|
+
guild,
|
|
973
|
+
botName: discordClient.user?.username,
|
|
974
|
+
appId,
|
|
975
|
+
isGatewayMode,
|
|
976
|
+
});
|
|
977
|
+
if (result) {
|
|
978
|
+
created.push({
|
|
979
|
+
name: result.channelName,
|
|
980
|
+
id: result.textChannelId,
|
|
981
|
+
guildId: guild.id,
|
|
982
|
+
});
|
|
983
|
+
// Send welcome message to the newly created default channel.
|
|
984
|
+
// Mention the installer so they get a notification.
|
|
985
|
+
const mentionUserId = installerDiscordUserId || guild.ownerId;
|
|
986
|
+
await sendWelcomeMessage({
|
|
987
|
+
channel: result.textChannel,
|
|
988
|
+
mentionUserId,
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch (error) {
|
|
993
|
+
cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return created;
|
|
997
|
+
}
|
|
961
998
|
/**
|
|
962
999
|
* Background initialization for quick start mode.
|
|
963
1000
|
* Starts OpenCode server and registers slash commands without blocking bot startup.
|
|
@@ -1454,7 +1491,8 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1454
1491
|
cliLogger.log('Starting Discord bot...');
|
|
1455
1492
|
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
1456
1493
|
cliLogger.log('Discord bot is running!');
|
|
1457
|
-
// Background channel sync + role reconciliation
|
|
1494
|
+
// Background channel sync + role reconciliation + default channel creation.
|
|
1495
|
+
// Never blocks ready state.
|
|
1458
1496
|
void (async () => {
|
|
1459
1497
|
try {
|
|
1460
1498
|
const backgroundChannels = await collectKimakiChannels({
|
|
@@ -1467,6 +1505,15 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1467
1505
|
catch (error) {
|
|
1468
1506
|
cliLogger.warn('Background channel sync failed:', error instanceof Error ? error.message : String(error));
|
|
1469
1507
|
}
|
|
1508
|
+
// Create default kimaki channel + welcome message in each guild.
|
|
1509
|
+
// Runs after channel sync so existing channels are detected correctly.
|
|
1510
|
+
await ensureDefaultChannelsWithWelcome({
|
|
1511
|
+
guilds,
|
|
1512
|
+
discordClient,
|
|
1513
|
+
appId,
|
|
1514
|
+
isGatewayMode,
|
|
1515
|
+
installerDiscordUserId,
|
|
1516
|
+
});
|
|
1470
1517
|
})();
|
|
1471
1518
|
// Background: OpenCode init + slash command registration (non-blocking)
|
|
1472
1519
|
void backgroundInit({
|
|
@@ -1609,33 +1656,14 @@ async function run({ restartOnboarding, addChannels, useWorktrees, enableVoiceCh
|
|
|
1609
1656
|
}
|
|
1610
1657
|
// Create default kimaki channel for general-purpose tasks.
|
|
1611
1658
|
// Runs for every guild the bot is in, idempotent (skips if already exists).
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
if (result) {
|
|
1621
|
-
createdChannels.push({
|
|
1622
|
-
name: result.channelName,
|
|
1623
|
-
id: result.textChannelId,
|
|
1624
|
-
guildId: guild.id,
|
|
1625
|
-
});
|
|
1626
|
-
// Send welcome message to the newly created default channel.
|
|
1627
|
-
// Mention the installer so they get a notification.
|
|
1628
|
-
const mentionUserId = installerDiscordUserId || guild.ownerId;
|
|
1629
|
-
await sendWelcomeMessage({
|
|
1630
|
-
channel: result.textChannel,
|
|
1631
|
-
mentionUserId,
|
|
1632
|
-
});
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
catch (error) {
|
|
1636
|
-
cliLogger.warn(`Failed to create default kimaki channel in ${guild.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1659
|
+
const defaultChannelResults = await ensureDefaultChannelsWithWelcome({
|
|
1660
|
+
guilds,
|
|
1661
|
+
discordClient,
|
|
1662
|
+
appId,
|
|
1663
|
+
isGatewayMode,
|
|
1664
|
+
installerDiscordUserId,
|
|
1665
|
+
});
|
|
1666
|
+
createdChannels.push(...defaultChannelResults);
|
|
1639
1667
|
// Log available user commands
|
|
1640
1668
|
const registrableCommands = allUserCommands.filter((cmd) => !SKIP_USER_COMMANDS.includes(cmd.name));
|
|
1641
1669
|
if (registrableCommands.length > 0) {
|
|
@@ -29,27 +29,28 @@ export async function showAskUserQuestionDropdowns({ thread, sessionId, director
|
|
|
29
29
|
contextHash,
|
|
30
30
|
};
|
|
31
31
|
pendingQuestionContexts.set(contextHash, context);
|
|
32
|
-
//
|
|
33
|
-
//
|
|
32
|
+
// On TTL expiry: hide the dropdown UI and abort the session so OpenCode
|
|
33
|
+
// unblocks. We intentionally do NOT call question.reply() — sending 'Other'
|
|
34
|
+
// made the model think the user chose an option when they didn't.
|
|
34
35
|
setTimeout(async () => {
|
|
35
36
|
const ctx = pendingQuestionContexts.get(contextHash);
|
|
36
37
|
if (!ctx) {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
40
|
+
// Delete context first so the dropdown becomes inert immediately.
|
|
41
|
+
// Without this, a user clicking during the abort() await would still
|
|
42
|
+
// be accepted by handleAskQuestionSelectMenu, then abort() would
|
|
43
|
+
// kill that valid run.
|
|
44
|
+
pendingQuestionContexts.delete(contextHash);
|
|
45
|
+
// Abort the session so OpenCode isn't stuck waiting for a reply
|
|
39
46
|
const client = getOpencodeClient(ctx.directory);
|
|
40
47
|
if (client) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
await client.question.reply({
|
|
45
|
-
requestID: ctx.requestId,
|
|
46
|
-
directory: ctx.directory,
|
|
47
|
-
answers,
|
|
48
|
+
await client.session.abort({
|
|
49
|
+
sessionID: ctx.sessionId,
|
|
48
50
|
}).catch((error) => {
|
|
49
|
-
logger.error('Failed to
|
|
51
|
+
logger.error('Failed to abort session after question expiry:', error);
|
|
50
52
|
});
|
|
51
53
|
}
|
|
52
|
-
pendingQuestionContexts.delete(contextHash);
|
|
53
54
|
}, QUESTION_CONTEXT_TTL_MS).unref();
|
|
54
55
|
// Send one message per question with its dropdown directly underneath
|
|
55
56
|
for (let i = 0; i < input.questions.length; i++) {
|
|
@@ -206,6 +207,11 @@ export function parseAskUserQuestionTool(part) {
|
|
|
206
207
|
/**
|
|
207
208
|
* Cancel a pending question for a thread (e.g., when user sends a new message).
|
|
208
209
|
* Sends the user's message as the answer to OpenCode so the model sees their actual response.
|
|
210
|
+
*
|
|
211
|
+
* Returns 'replied' if the question was answered successfully (caller should NOT
|
|
212
|
+
* enqueue the user message as a new prompt — it was consumed as the answer).
|
|
213
|
+
* Returns 'reply-failed' if reply failed (context kept pending so TTL can retry).
|
|
214
|
+
* Returns 'no-pending' if no question was pending for this thread.
|
|
209
215
|
*/
|
|
210
216
|
export async function cancelPendingQuestion(threadId, userMessage) {
|
|
211
217
|
// Find pending question for this thread
|
|
@@ -219,17 +225,22 @@ export async function cancelPendingQuestion(threadId, userMessage) {
|
|
|
219
225
|
}
|
|
220
226
|
}
|
|
221
227
|
if (!contextHash || !context) {
|
|
222
|
-
return
|
|
228
|
+
return 'no-pending';
|
|
229
|
+
}
|
|
230
|
+
// undefined means teardown/cleanup — just remove context, don't reply.
|
|
231
|
+
// The session is already being torn down. Empty string '' is a valid
|
|
232
|
+
// user message (attachment-only, voice, etc.) and must still go through.
|
|
233
|
+
if (userMessage === undefined) {
|
|
234
|
+
pendingQuestionContexts.delete(contextHash);
|
|
235
|
+
return 'no-pending';
|
|
223
236
|
}
|
|
224
237
|
try {
|
|
225
238
|
const client = getOpencodeClient(context.directory);
|
|
226
239
|
if (!client) {
|
|
227
240
|
throw new Error('OpenCode server not found for directory');
|
|
228
241
|
}
|
|
229
|
-
// Use user's message as answer if provided, otherwise mark as "Other"
|
|
230
|
-
const customAnswer = userMessage || 'Other';
|
|
231
242
|
const answers = context.questions.map((_, i) => {
|
|
232
|
-
return context.answers[i] || [
|
|
243
|
+
return context.answers[i] || [userMessage];
|
|
233
244
|
});
|
|
234
245
|
await client.question.reply({
|
|
235
246
|
requestID: context.requestId,
|
|
@@ -240,8 +251,10 @@ export async function cancelPendingQuestion(threadId, userMessage) {
|
|
|
240
251
|
}
|
|
241
252
|
catch (error) {
|
|
242
253
|
logger.error('Failed to answer question:', error);
|
|
254
|
+
// Keep context pending so TTL can still fire.
|
|
255
|
+
// Caller should not consume the user message since reply failed.
|
|
256
|
+
return 'reply-failed';
|
|
243
257
|
}
|
|
244
|
-
// Clean up regardless of whether the API call succeeded
|
|
245
258
|
pendingQuestionContexts.delete(contextHash);
|
|
246
|
-
return
|
|
259
|
+
return 'replied';
|
|
247
260
|
}
|
package/dist/discord-bot.js
CHANGED
|
@@ -166,10 +166,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
166
166
|
const channels = await getChannelsWithDescriptions(guild);
|
|
167
167
|
const kimakiChannels = channels.filter((ch) => ch.kimakiDirectory);
|
|
168
168
|
if (kimakiChannels.length > 0) {
|
|
169
|
-
discordLogger.log(` Found ${kimakiChannels.length} channel(s) for this bot
|
|
170
|
-
for (const channel of kimakiChannels) {
|
|
171
|
-
discordLogger.log(` - #${channel.name}: ${channel.kimakiDirectory}`);
|
|
172
|
-
}
|
|
169
|
+
discordLogger.log(` Found ${kimakiChannels.length} channel(s) for this bot`);
|
|
173
170
|
continue;
|
|
174
171
|
}
|
|
175
172
|
discordLogger.log(' No channels for this bot');
|
|
@@ -333,11 +330,18 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
333
330
|
if (isThread) {
|
|
334
331
|
const thread = channel;
|
|
335
332
|
discordLogger.log(`Message in thread ${thread.name} (${thread.id})`);
|
|
333
|
+
// Cancel interactive UI when a real user sends a message.
|
|
334
|
+
// If a question was pending and answered with the user's text,
|
|
335
|
+
// early-return: the message was consumed as the question answer
|
|
336
|
+
// and must NOT also be sent as a new prompt (causes abort loops).
|
|
336
337
|
if (!message.author.bot && !isCliInjectedPrompt) {
|
|
337
338
|
cancelPendingActionButtons(thread.id);
|
|
338
339
|
cancelHtmlActionsForThread(thread.id);
|
|
339
|
-
|
|
340
|
+
const questionResult = await cancelPendingQuestion(thread.id, message.content);
|
|
340
341
|
void cancelPendingFileUpload(thread.id);
|
|
342
|
+
if (questionResult === 'replied') {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
341
345
|
}
|
|
342
346
|
const parent = thread.parent;
|
|
343
347
|
let projectDirectory;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Experiment: test if eventsource-parser can extract `data:` lines from noisy process output
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
import { createParser } from 'eventsource-parser';
|
|
4
|
+
function parseSSEFromChunks(chunks) {
|
|
5
|
+
const events = [];
|
|
6
|
+
const parser = createParser({
|
|
7
|
+
onEvent(event) {
|
|
8
|
+
events.push(event);
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
for (const chunk of chunks) {
|
|
12
|
+
parser.feed(chunk);
|
|
13
|
+
}
|
|
14
|
+
return events;
|
|
15
|
+
}
|
|
16
|
+
describe('eventsource-parser with noisy process output', () => {
|
|
17
|
+
test('extracts data: json lines from garbage output', () => {
|
|
18
|
+
const chunks = [
|
|
19
|
+
'Starting server on port 3000...\n',
|
|
20
|
+
'[INFO] Loading configuration\n',
|
|
21
|
+
'WARNING: deprecated API usage detected\n',
|
|
22
|
+
'data: {"type":"start","id":1}\n\n',
|
|
23
|
+
'Compiling 42 modules...\n',
|
|
24
|
+
'✓ Built in 1.2s\n',
|
|
25
|
+
'[DEBUG] cache miss for key abc123\n',
|
|
26
|
+
'data: {"type":"progress","percent":50}\n\n',
|
|
27
|
+
'error: ENOENT /tmp/missing.txt (non-fatal, skipping)\n',
|
|
28
|
+
' at Object.openSync (node:fs:601:3)\n',
|
|
29
|
+
' at readFileSync (node:fs:469:35)\n',
|
|
30
|
+
'data: {"type":"result","payload":{"name":"test","value":42}}\n\n',
|
|
31
|
+
'Shutting down gracefully...\n',
|
|
32
|
+
'[METRIC] requests=1024 latency_p99=12ms\n',
|
|
33
|
+
'data: {"type":"end","id":4}\n\n',
|
|
34
|
+
];
|
|
35
|
+
const events = parseSSEFromChunks(chunks);
|
|
36
|
+
const parsed = events.map((e) => {
|
|
37
|
+
return JSON.parse(e.data);
|
|
38
|
+
});
|
|
39
|
+
expect(parsed).toMatchInlineSnapshot(`
|
|
40
|
+
[
|
|
41
|
+
{
|
|
42
|
+
"id": 1,
|
|
43
|
+
"type": "start",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"percent": 50,
|
|
47
|
+
"type": "progress",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"payload": {
|
|
51
|
+
"name": "test",
|
|
52
|
+
"value": 42,
|
|
53
|
+
},
|
|
54
|
+
"type": "result",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": 4,
|
|
58
|
+
"type": "end",
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
`);
|
|
62
|
+
});
|
|
63
|
+
test('handles data: lines split across chunks', () => {
|
|
64
|
+
const chunks = [
|
|
65
|
+
'some garbage\n',
|
|
66
|
+
'dat',
|
|
67
|
+
'a: {"split":true}\n\n',
|
|
68
|
+
'more garbage\n',
|
|
69
|
+
];
|
|
70
|
+
const events = parseSSEFromChunks(chunks);
|
|
71
|
+
const parsed = events.map((e) => {
|
|
72
|
+
return JSON.parse(e.data);
|
|
73
|
+
});
|
|
74
|
+
expect(parsed).toMatchInlineSnapshot(`
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
"split": true,
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
`);
|
|
81
|
+
});
|
|
82
|
+
test('handles multi-line data fields', () => {
|
|
83
|
+
const chunks = [
|
|
84
|
+
'[LOG] something\n',
|
|
85
|
+
'data: {"line":1}\n',
|
|
86
|
+
'data: {"line":2}\n\n',
|
|
87
|
+
'noise\n',
|
|
88
|
+
];
|
|
89
|
+
const events = parseSSEFromChunks(chunks);
|
|
90
|
+
// multi-line data gets joined with newlines per SSE spec
|
|
91
|
+
expect(events.map((e) => {
|
|
92
|
+
return e.data;
|
|
93
|
+
})).toMatchInlineSnapshot(`
|
|
94
|
+
[
|
|
95
|
+
"{"line":1}
|
|
96
|
+
{"line":2}",
|
|
97
|
+
]
|
|
98
|
+
`);
|
|
99
|
+
});
|
|
100
|
+
test('ignores lines that look like data but are not SSE format', () => {
|
|
101
|
+
const chunks = [
|
|
102
|
+
'database: connection established\n',
|
|
103
|
+
'data: {"real":"event"}\n\n',
|
|
104
|
+
'datadir: /var/lib/app\n',
|
|
105
|
+
'data:no-space-after-colon\n\n',
|
|
106
|
+
' data: indented-data-line\n\n',
|
|
107
|
+
];
|
|
108
|
+
const events = parseSSEFromChunks(chunks);
|
|
109
|
+
expect(events.map((e) => {
|
|
110
|
+
return e.data;
|
|
111
|
+
})).toMatchInlineSnapshot(`
|
|
112
|
+
[
|
|
113
|
+
"{"real":"event"}",
|
|
114
|
+
"no-space-after-colon",
|
|
115
|
+
]
|
|
116
|
+
`);
|
|
117
|
+
});
|
|
118
|
+
test('data: in middle of a line', () => {
|
|
119
|
+
const chunks = [
|
|
120
|
+
'some prefix data: {"mid":true}\n\n',
|
|
121
|
+
'the output is data: not this\n\n',
|
|
122
|
+
'data: {"real":"event"}\n\n',
|
|
123
|
+
'foo=bar data: {"also":"mid"} more stuff\n\n',
|
|
124
|
+
'[2024-01-01] data: {"log":"entry"}\n\n',
|
|
125
|
+
];
|
|
126
|
+
const events = parseSSEFromChunks(chunks);
|
|
127
|
+
expect(events.map((e) => {
|
|
128
|
+
return e.data;
|
|
129
|
+
})).toMatchInlineSnapshot(`
|
|
130
|
+
[
|
|
131
|
+
"{"real":"event"}",
|
|
132
|
+
]
|
|
133
|
+
`);
|
|
134
|
+
});
|
|
135
|
+
test('raw json without data: prefix', () => {
|
|
136
|
+
const chunks = [
|
|
137
|
+
'{"bare":"json"}\n\n',
|
|
138
|
+
'data: {"real":"event"}\n\n',
|
|
139
|
+
'some text {"embedded":"json"} more text\n\n',
|
|
140
|
+
'{"start":"of line"} trailing\n\n',
|
|
141
|
+
' {"indented":"json"}\n\n',
|
|
142
|
+
'[{"array":"json"},{"second":"obj"}]\n\n',
|
|
143
|
+
'data: {"second":"real"}\n\n',
|
|
144
|
+
];
|
|
145
|
+
const events = parseSSEFromChunks(chunks);
|
|
146
|
+
expect(events.map((e) => {
|
|
147
|
+
return e.data;
|
|
148
|
+
})).toMatchInlineSnapshot(`
|
|
149
|
+
[
|
|
150
|
+
"{"real":"event"}",
|
|
151
|
+
"{"second":"real"}",
|
|
152
|
+
]
|
|
153
|
+
`);
|
|
154
|
+
});
|
|
155
|
+
test('other SSE fields from process noise pollute event metadata', () => {
|
|
156
|
+
const chunks = [
|
|
157
|
+
// process outputs that happen to match SSE field names
|
|
158
|
+
'id: proc-12345\n',
|
|
159
|
+
'event: error\n',
|
|
160
|
+
'retry: 5000\n',
|
|
161
|
+
': this is a comment\n',
|
|
162
|
+
'data: {"real":"payload"}\n\n',
|
|
163
|
+
];
|
|
164
|
+
const events = parseSSEFromChunks(chunks);
|
|
165
|
+
// check if the garbage id:/event: lines leaked into the real event
|
|
166
|
+
expect(events.map((e) => {
|
|
167
|
+
return { data: e.data, id: e.id, event: e.event };
|
|
168
|
+
})).toMatchInlineSnapshot(`
|
|
169
|
+
[
|
|
170
|
+
{
|
|
171
|
+
"data": "{"real":"payload"}",
|
|
172
|
+
"event": "error",
|
|
173
|
+
"id": "proc-12345",
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
`);
|
|
177
|
+
});
|
|
178
|
+
test('event: between two data events only affects the next one', () => {
|
|
179
|
+
const chunks = [
|
|
180
|
+
'data: {"first":"clean"}\n\n',
|
|
181
|
+
'event: contaminated\n',
|
|
182
|
+
'data: {"second":"dirty?"}\n\n',
|
|
183
|
+
'data: {"third":"clean again?"}\n\n',
|
|
184
|
+
];
|
|
185
|
+
const events = parseSSEFromChunks(chunks);
|
|
186
|
+
expect(events.map((e) => {
|
|
187
|
+
return { data: e.data, event: e.event };
|
|
188
|
+
})).toMatchInlineSnapshot(`
|
|
189
|
+
[
|
|
190
|
+
{
|
|
191
|
+
"data": "{"first":"clean"}",
|
|
192
|
+
"event": undefined,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"data": "{"second":"dirty?"}",
|
|
196
|
+
"event": "contaminated",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"data": "{"third":"clean again?"}",
|
|
200
|
+
"event": undefined,
|
|
201
|
+
},
|
|
202
|
+
]
|
|
203
|
+
`);
|
|
204
|
+
});
|
|
205
|
+
test('id: from noise persists across events', () => {
|
|
206
|
+
const chunks = [
|
|
207
|
+
'data: {"before":"id"}\n\n',
|
|
208
|
+
'id: noise-id-999\n',
|
|
209
|
+
'data: {"after":"id"}\n\n',
|
|
210
|
+
'data: {"later":"event"}\n\n',
|
|
211
|
+
];
|
|
212
|
+
const events = parseSSEFromChunks(chunks);
|
|
213
|
+
expect(events.map((e) => {
|
|
214
|
+
return { data: e.data, id: e.id };
|
|
215
|
+
})).toMatchInlineSnapshot(`
|
|
216
|
+
[
|
|
217
|
+
{
|
|
218
|
+
"data": "{"before":"id"}",
|
|
219
|
+
"id": undefined,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"data": "{"after":"id"}",
|
|
223
|
+
"id": "noise-id-999",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"data": "{"later":"event"}",
|
|
227
|
+
"id": undefined,
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
`);
|
|
231
|
+
});
|
|
232
|
+
test('realistic process output with dangerous prefixes', () => {
|
|
233
|
+
const chunks = [
|
|
234
|
+
'event loop blocked for 200ms\n',
|
|
235
|
+
'id: user-abc logged in\n',
|
|
236
|
+
'retry after 3 attempts\n',
|
|
237
|
+
'data: {"safe":"event"}\n\n',
|
|
238
|
+
'identifier: session-xyz\n',
|
|
239
|
+
'eventually consistent\n',
|
|
240
|
+
'retrying connection...\n',
|
|
241
|
+
'data: {"second":"event"}\n\n',
|
|
242
|
+
];
|
|
243
|
+
const events = parseSSEFromChunks(chunks);
|
|
244
|
+
expect(events.map((e) => {
|
|
245
|
+
return { data: e.data, id: e.id, event: e.event };
|
|
246
|
+
})).toMatchInlineSnapshot(`
|
|
247
|
+
[
|
|
248
|
+
{
|
|
249
|
+
"data": "{"safe":"event"}",
|
|
250
|
+
"event": undefined,
|
|
251
|
+
"id": "user-abc logged in",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"data": "{"second":"event"}",
|
|
255
|
+
"event": undefined,
|
|
256
|
+
"id": undefined,
|
|
257
|
+
},
|
|
258
|
+
]
|
|
259
|
+
`);
|
|
260
|
+
});
|
|
261
|
+
test('works with rapid interleaved garbage and data', () => {
|
|
262
|
+
const garbage = [
|
|
263
|
+
'0x7fff5fbff8c0',
|
|
264
|
+
'Segfault at 0xDEADBEEF (just kidding)',
|
|
265
|
+
'█████████░░░░ 65%',
|
|
266
|
+
'🔥 hot reload triggered',
|
|
267
|
+
'npm warn deprecated lodash@3.0.0',
|
|
268
|
+
];
|
|
269
|
+
const jsonPayloads = Array.from({ length: 10 }, (_, i) => {
|
|
270
|
+
return { seq: i, ts: 1000 + i };
|
|
271
|
+
});
|
|
272
|
+
const chunks = jsonPayloads.flatMap((payload, i) => {
|
|
273
|
+
return [
|
|
274
|
+
`${garbage[i % garbage.length]}\n`,
|
|
275
|
+
`data: ${JSON.stringify(payload)}\n\n`,
|
|
276
|
+
];
|
|
277
|
+
});
|
|
278
|
+
const events = parseSSEFromChunks(chunks);
|
|
279
|
+
const parsed = events.map((e) => {
|
|
280
|
+
return JSON.parse(e.data);
|
|
281
|
+
});
|
|
282
|
+
expect(parsed).toMatchInlineSnapshot(`
|
|
283
|
+
[
|
|
284
|
+
{
|
|
285
|
+
"seq": 0,
|
|
286
|
+
"ts": 1000,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
"seq": 1,
|
|
290
|
+
"ts": 1001,
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
"seq": 2,
|
|
294
|
+
"ts": 1002,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"seq": 3,
|
|
298
|
+
"ts": 1003,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"seq": 4,
|
|
302
|
+
"ts": 1004,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"seq": 5,
|
|
306
|
+
"ts": 1005,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"seq": 6,
|
|
310
|
+
"ts": 1006,
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
"seq": 7,
|
|
314
|
+
"ts": 1007,
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
"seq": 8,
|
|
318
|
+
"ts": 1008,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"seq": 9,
|
|
322
|
+
"ts": 1009,
|
|
323
|
+
},
|
|
324
|
+
]
|
|
325
|
+
`);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -16,10 +16,12 @@ import { createLogger, LogPrefix } from './logger.js';
|
|
|
16
16
|
import { notifyError } from './sentry.js';
|
|
17
17
|
const logger = createLogger(LogPrefix.SESSION);
|
|
18
18
|
const voiceLogger = createLogger(LogPrefix.VOICE);
|
|
19
|
-
// Matches "
|
|
19
|
+
// Matches punctuation + "queue" at the end of a message (case-insensitive).
|
|
20
|
+
// Supports any common punctuation before "queue" (. ! ? , ; :) and an optional
|
|
21
|
+
// trailing period: ". queue", "! queue", ". queue.", "!queue." etc.
|
|
20
22
|
// When present the suffix is stripped and the message is routed through
|
|
21
23
|
// kimaki's local queue (same as /queue command).
|
|
22
|
-
const QUEUE_SUFFIX_RE =
|
|
24
|
+
const QUEUE_SUFFIX_RE = /[.!?,;:]\s*queue\.?\s*$/i;
|
|
23
25
|
function extractQueueSuffix(prompt) {
|
|
24
26
|
if (!QUEUE_SUFFIX_RE.test(prompt)) {
|
|
25
27
|
return { prompt, forceQueue: false };
|
|
@@ -265,11 +265,46 @@ export function createDeterministicMatchers() {
|
|
|
265
265
|
],
|
|
266
266
|
},
|
|
267
267
|
};
|
|
268
|
+
// Question tool: model asks a question, user answers via text, model follows up
|
|
269
|
+
const questionToolMatcher = {
|
|
270
|
+
id: 'question-text-answer-marker',
|
|
271
|
+
priority: 106,
|
|
272
|
+
when: {
|
|
273
|
+
lastMessageRole: 'user',
|
|
274
|
+
latestUserTextIncludes: 'QUESTION_TEXT_ANSWER_MARKER',
|
|
275
|
+
},
|
|
276
|
+
then: {
|
|
277
|
+
parts: [
|
|
278
|
+
{ type: 'stream-start', warnings: [] },
|
|
279
|
+
{
|
|
280
|
+
type: 'tool-call',
|
|
281
|
+
toolCallId: 'question-text-answer-call',
|
|
282
|
+
toolName: 'question',
|
|
283
|
+
input: JSON.stringify({
|
|
284
|
+
questions: [{
|
|
285
|
+
question: 'Which option do you prefer?',
|
|
286
|
+
header: 'Pick one',
|
|
287
|
+
options: [
|
|
288
|
+
{ label: 'Alpha', description: 'Alpha option' },
|
|
289
|
+
{ label: 'Beta', description: 'Beta option' },
|
|
290
|
+
],
|
|
291
|
+
}],
|
|
292
|
+
}),
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
type: 'finish',
|
|
296
|
+
finishReason: 'tool-calls',
|
|
297
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
};
|
|
268
302
|
return [
|
|
269
303
|
slowAbortMatcher,
|
|
270
304
|
typingRepulseMatcher,
|
|
271
305
|
pluginTimeoutSleepMatcher,
|
|
272
306
|
actionButtonClickFollowupMatcher,
|
|
307
|
+
questionToolMatcher,
|
|
273
308
|
permissionTypingMatcher,
|
|
274
309
|
permissionTypingFollowupMatcher,
|
|
275
310
|
raceFinalReplyMatcher,
|