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 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
- // Consumers can detect these by reading stdout line-by-line and JSON.parse-ing lines
196
- // that start with '{'.
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) + '\n');
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 never blocks ready state.
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
- for (const guild of guilds) {
1613
- try {
1614
- const result = await createDefaultKimakiChannel({
1615
- guild,
1616
- botName: discordClient.user?.username,
1617
- appId,
1618
- isGatewayMode,
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
- // Auto-answer on TTL expiry so the OpenCode session doesn't hang forever
33
- // waiting for a question reply that will never come.
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
- const answers = ctx.questions.map((_, i) => {
42
- return ctx.answers[i] || ['Other'];
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 auto-answer expired question:', error);
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 false;
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] || [customAnswer];
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 true;
259
+ return 'replied';
247
260
  }
@@ -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
- void cancelPendingQuestion(thread.id, message.content);
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 ". queue" at the end of a message (case-insensitive, ignoring trailing whitespace).
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 = /\.\s*queue\s*$/i;
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,