kimaki 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/bash-tool-schema-plugin.js +18 -0
  2. package/dist/bash-tool-schema-plugin.test.js +20 -1
  3. package/dist/channel-management.js +113 -51
  4. package/dist/cli-commands/misc.js +4 -1
  5. package/dist/cli-commands/project.js +7 -4
  6. package/dist/cli-commands/session.js +84 -33
  7. package/dist/cli-commands/thread.js +63 -0
  8. package/dist/cli.js +2 -0
  9. package/dist/commands/queue.js +79 -31
  10. package/dist/commands/queue.test.js +15 -0
  11. package/dist/commands/restart-opencode-server.js +7 -1
  12. package/dist/commands/session-id.js +5 -5
  13. package/dist/database.js +76 -0
  14. package/dist/db.js +34 -0
  15. package/dist/db.test.js +96 -1
  16. package/dist/default-channel-provisioning.test.js +2 -1
  17. package/dist/discord-bot.js +9 -3
  18. package/dist/discord-command-registration.js +1 -1
  19. package/dist/guild-categories.test.js +225 -0
  20. package/dist/interaction-handler.js +5 -1
  21. package/dist/kimaki-opencode-plugin.js +2 -0
  22. package/dist/kimaki-opencode-plugin.test.js +28 -0
  23. package/dist/list-channel-threads.js +163 -0
  24. package/dist/list-channel-threads.test.js +80 -0
  25. package/dist/message-formatting.js +9 -16
  26. package/dist/message-formatting.test.js +91 -2
  27. package/dist/schema.js +21 -0
  28. package/dist/session-handler/event-stream-state.js +53 -5
  29. package/dist/session-handler/event-stream-state.test.js +223 -0
  30. package/dist/session-handler/thread-runtime-state.js +4 -0
  31. package/dist/session-handler/thread-session-runtime.js +314 -73
  32. package/dist/system-message.js +55 -13
  33. package/dist/system-message.test.js +50 -13
  34. package/dist/task-id-plugin.js +16 -0
  35. package/package.json +5 -5
  36. package/skills/strada/SKILL.md +61 -232
  37. package/src/bash-tool-schema-plugin.test.ts +29 -0
  38. package/src/bash-tool-schema-plugin.ts +31 -0
  39. package/src/channel-management.ts +144 -65
  40. package/src/cli-commands/misc.ts +4 -1
  41. package/src/cli-commands/project.ts +9 -4
  42. package/src/cli-commands/session.ts +123 -34
  43. package/src/cli-commands/thread.ts +86 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/queue.test.ts +19 -0
  46. package/src/commands/queue.ts +106 -40
  47. package/src/commands/restart-opencode-server.ts +12 -1
  48. package/src/commands/session-id.ts +5 -5
  49. package/src/database.ts +114 -0
  50. package/src/db.test.ts +112 -0
  51. package/src/db.ts +38 -0
  52. package/src/default-channel-provisioning.test.ts +2 -1
  53. package/src/discord-bot.ts +11 -2
  54. package/src/discord-command-registration.ts +3 -1
  55. package/src/guild-categories.test.ts +260 -0
  56. package/src/interaction-handler.ts +7 -0
  57. package/src/kimaki-opencode-plugin.test.ts +34 -0
  58. package/src/kimaki-opencode-plugin.ts +2 -0
  59. package/src/list-channel-threads.test.ts +90 -0
  60. package/src/list-channel-threads.ts +252 -0
  61. package/src/message-formatting.test.ts +105 -1
  62. package/src/message-formatting.ts +7 -12
  63. package/src/schema.sql +16 -0
  64. package/src/schema.ts +23 -0
  65. package/src/session-handler/event-stream-state.test.ts +226 -0
  66. package/src/session-handler/event-stream-state.ts +57 -5
  67. package/src/session-handler/thread-runtime-state.ts +5 -0
  68. package/src/session-handler/thread-session-runtime.ts +380 -81
  69. package/src/system-message.test.ts +50 -13
  70. package/src/system-message.ts +58 -12
  71. package/src/task-id-plugin.ts +17 -0
@@ -42,6 +42,28 @@ describe('formatPart', () => {
42
42
  Done."
43
43
  `);
44
44
  });
45
+ test('synthetic user context stays hidden', () => {
46
+ const part = {
47
+ id: 'prt_branch',
48
+ type: 'text',
49
+ sessionID: 'ses_test',
50
+ messageID: 'msg_user',
51
+ text: '\n[current git branch is main]\n',
52
+ synthetic: true,
53
+ };
54
+ expect(formatPart(part)).toBe('');
55
+ });
56
+ test('ignored plugin notices stay visible', () => {
57
+ const part = {
58
+ id: 'prt_notice',
59
+ type: 'text',
60
+ sessionID: 'ses_test',
61
+ messageID: 'msg_notice',
62
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
63
+ ignored: true,
64
+ };
65
+ expect(formatPart(part)).toBe('Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.');
66
+ });
45
67
  });
46
68
  describe('asDiscordQuote', () => {
47
69
  test('prefixes every line', () => {
@@ -144,7 +166,7 @@ describe('planAssistantTurnFlush', () => {
144
166
  }
145
167
  `);
146
168
  });
147
- test('progress holds unfinished last text and following tools', () => {
169
+ test('progress sends tools immediately while unfinished text is held', () => {
148
170
  expect(plan([text('t1', 'status', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
149
171
  {
150
172
  "hold": [
@@ -152,15 +174,51 @@ describe('planAssistantTurnFlush', () => {
152
174
  "id": "t1",
153
175
  "quoteText": false,
154
176
  },
177
+ ],
178
+ "send": [
155
179
  {
156
180
  "id": "tool1",
157
181
  "quoteText": false,
158
182
  },
159
183
  ],
184
+ }
185
+ `);
186
+ });
187
+ test('progress holds unfinished text only when nothing else is ready', () => {
188
+ expect(plan([text('t1', 'status', false)], 'progress')).toMatchInlineSnapshot(`
189
+ {
190
+ "hold": [
191
+ {
192
+ "id": "t1",
193
+ "quoteText": false,
194
+ },
195
+ ],
160
196
  "send": [],
161
197
  }
162
198
  `);
163
199
  });
200
+ test('progress sends completed text and later tools while later text is still open', () => {
201
+ expect(plan([text('t1', 'first'), text('t2', 'second', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
202
+ {
203
+ "hold": [
204
+ {
205
+ "id": "t2",
206
+ "quoteText": false,
207
+ },
208
+ ],
209
+ "send": [
210
+ {
211
+ "id": "t1",
212
+ "quoteText": true,
213
+ },
214
+ {
215
+ "id": "tool1",
216
+ "quoteText": false,
217
+ },
218
+ ],
219
+ }
220
+ `);
221
+ });
164
222
  test('progress holds unfinished last text until it ends', () => {
165
223
  expect(plan([text('t1', 'first'), text('t2', 'second', false)], 'progress')).toMatchInlineSnapshot(`
166
224
  {
@@ -284,6 +342,17 @@ describe('planAssistantTurnFlush', () => {
284
342
  test('progress still quotes two-line earlier text', () => {
285
343
  expect(plan([text('t1', 'line one\nline two'), tool('tool1'), text('t2', 'answer')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: true });
286
344
  });
345
+ test('progress keeps sleep-tool quote context after that tool is already sent', () => {
346
+ const parts = [text('t1', 'wait'), tool('sleep1', 'kimaki_sleep')];
347
+ expect(plan(parts, 'progress').send.find((entry) => entry.id === 't1')).toEqual({
348
+ id: 't1',
349
+ quoteText: false,
350
+ });
351
+ expect(plan([text('t1', 'wait')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({
352
+ id: 't1',
353
+ quoteText: true,
354
+ });
355
+ });
287
356
  test('progress does not quote earlier text before an interactive tool', () => {
288
357
  expect(plan([text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
289
358
  expect(plan([text('t1', 'wait'), tool('sleep1', 'kimaki_sleep'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
@@ -318,15 +387,35 @@ describe('sessionPartContent', () => {
318
387
  });
319
388
  });
320
389
  describe('collectSessionChunks', () => {
321
- function textPart({ id, text, messageID, }) {
390
+ function textPart({ id, text, messageID, synthetic, }) {
322
391
  return {
323
392
  id,
324
393
  type: 'text',
325
394
  sessionID: 'ses_test',
326
395
  messageID,
327
396
  text,
397
+ synthetic,
328
398
  };
329
399
  }
400
+ test('skips synthetic text parts', () => {
401
+ const { chunks } = collectSessionChunks({
402
+ messages: [
403
+ {
404
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
405
+ parts: [
406
+ textPart({
407
+ id: 'branch',
408
+ text: '\n[current git branch is main]\n',
409
+ messageID: 'msg_1',
410
+ synthetic: true,
411
+ }),
412
+ textPart({ id: 't1', text: 'working', messageID: 'msg_1' }),
413
+ ],
414
+ },
415
+ ],
416
+ });
417
+ expect(chunks.map((chunk) => chunk.content)).toEqual(['> working']);
418
+ });
330
419
  test('quotes short text throughout a turn', () => {
331
420
  const { chunks } = collectSessionChunks({
332
421
  messages: [
package/dist/schema.js CHANGED
@@ -32,6 +32,15 @@ export const thread_sessions = sqliteCore.sqliteTable('thread_sessions', {
32
32
  // Without it the reverse lookup is arbitrary and tools can target a dead thread.
33
33
  updated_at: datetime('updated_at').default(orm.sql `CURRENT_TIMESTAMP`).$onUpdate(() => new Date()),
34
34
  });
35
+ export const thread_queue_items = sqliteCore.sqliteTable('thread_queue_items', {
36
+ id: sqliteCore.integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }).notNull(),
37
+ queue_id: sqliteCore.text('queue_id').notNull().unique(),
38
+ thread_id: sqliteCore.text('thread_id').notNull(),
39
+ payload_json: sqliteCore.text('payload_json').notNull(),
40
+ created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
41
+ }, (table) => [
42
+ sqliteCore.index('thread_queue_items_thread_id_id_idx').on(table.thread_id, table.id),
43
+ ]);
35
44
  export const session_events = sqliteCore.sqliteTable('session_events', {
36
45
  id: sqliteCore.integer('id', { mode: 'number' }).primaryKey({ autoIncrement: true }).notNull(),
37
46
  session_id: sqliteCore.text('session_id').notNull(),
@@ -68,6 +77,14 @@ export const channel_directories = sqliteCore.sqliteTable('channel_directories',
68
77
  guild_id: sqliteCore.text('guild_id'),
69
78
  created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
70
79
  });
80
+ // One Discord category pair per guild for this machine. Look up by snowflake
81
+ // id, never by name, so a rename still routes new channels here.
82
+ export const guild_categories = sqliteCore.sqliteTable('guild_categories', {
83
+ guild_id: sqliteCore.text('guild_id').primaryKey().notNull(),
84
+ category_id: sqliteCore.text('category_id'),
85
+ audio_category_id: sqliteCore.text('audio_category_id'),
86
+ created_at: datetime('created_at').default(orm.sql `CURRENT_TIMESTAMP`),
87
+ });
71
88
  export const bot_api_keys = sqliteCore.sqliteTable('bot_api_keys', {
72
89
  app_id: sqliteCore.text('app_id').primaryKey().notNull().references(() => bot_tokens.app_id, { onUpdate: 'cascade' }),
73
90
  gemini_api_key: sqliteCore.text('gemini_api_key'),
@@ -246,6 +263,7 @@ export const ipc_requests = sqliteCore.sqliteTable('ipc_requests', {
246
263
  ]);
247
264
  export const relations = defineRelations({
248
265
  thread_sessions,
266
+ thread_queue_items,
249
267
  session_events,
250
268
  part_messages,
251
269
  bot_tokens,
@@ -253,6 +271,7 @@ export const relations = defineRelations({
253
271
  thread_worktrees,
254
272
  thread_workspaces,
255
273
  channel_directories,
274
+ guild_categories,
256
275
  channel_models,
257
276
  session_models,
258
277
  channel_agents,
@@ -276,6 +295,7 @@ export const relations = defineRelations({
276
295
  thread_workspace: r.one.thread_workspaces({ from: r.thread_sessions.thread_id, to: r.thread_workspaces.thread_id }),
277
296
  ipc_requests: r.many.ipc_requests(),
278
297
  },
298
+ thread_queue_items: {},
279
299
  session_events: {
280
300
  thread: r.one.thread_sessions({ from: r.session_events.thread_id, to: r.thread_sessions.thread_id }),
281
301
  },
@@ -296,6 +316,7 @@ export const relations = defineRelations({
296
316
  thread_workspaces: {
297
317
  thread: r.one.thread_sessions({ from: r.thread_workspaces.thread_id, to: r.thread_sessions.thread_id }),
298
318
  },
319
+ guild_categories: {},
299
320
  channel_directories: {
300
321
  channel_model: r.one.channel_models({ from: r.channel_directories.channel_id, to: r.channel_models.channel_id }),
301
322
  channel_agent: r.one.channel_agents({ from: r.channel_directories.channel_id, to: r.channel_agents.channel_id }),
@@ -17,6 +17,9 @@ function isCompactionContinuePart(part) {
17
17
  }
18
18
  function isInternalOpenCodeUserMessageId({ events, sessionId, messageId, upToIndex, }) {
19
19
  const end = upToIndex ?? events.length - 1;
20
+ let sawPart = false;
21
+ let hasUserTurnInput = false;
22
+ let hasIgnoredNotice = false;
20
23
  for (let i = 0; i <= end; i++) {
21
24
  const event = events[i]?.event;
22
25
  if (event?.type !== 'message.part.updated') {
@@ -26,14 +29,29 @@ function isInternalOpenCodeUserMessageId({ events, sessionId, messageId, upToInd
26
29
  if (part.sessionID !== sessionId || part.messageID !== messageId) {
27
30
  continue;
28
31
  }
32
+ sawPart = true;
29
33
  if (part.type === 'compaction') {
30
34
  return true;
31
35
  }
32
36
  if (isCompactionContinuePart(part)) {
33
37
  return true;
34
38
  }
39
+ if (part.type === 'text') {
40
+ if (part.synthetic === true) {
41
+ continue;
42
+ }
43
+ if (part.ignored === true) {
44
+ hasIgnoredNotice = true;
45
+ continue;
46
+ }
47
+ if (part.text?.trim()) {
48
+ hasUserTurnInput = true;
49
+ }
50
+ continue;
51
+ }
52
+ hasUserTurnInput = true;
35
53
  }
36
- return false;
54
+ return sawPart && hasIgnoredNotice && !hasUserTurnInput;
37
55
  }
38
56
  function isUserFacingAssistantMessage(message) {
39
57
  return message.summary !== true;
@@ -581,6 +599,36 @@ export function getAssistantMessageIdsForLatestUserTurn({ events, sessionId, upT
581
599
  return new Set();
582
600
  }
583
601
  const end = upToIndex === undefined ? events.length : upToIndex + 1;
602
+ const firstUserMessageIndexes = new Map();
603
+ for (let i = 0; i < end; i++) {
604
+ const event = events[i]?.event;
605
+ if (event?.type !== 'message.updated') {
606
+ continue;
607
+ }
608
+ const message = event.properties.info;
609
+ if (message.sessionID === sessionId
610
+ && message.role === 'user'
611
+ && !firstUserMessageIndexes.has(message.id)) {
612
+ firstUserMessageIndexes.set(message.id, i);
613
+ }
614
+ }
615
+ const latestUserMessageIndex = firstUserMessageIndexes.get(latestUserMessage.id);
616
+ const turnParentMessageIds = new Set([latestUserMessage.id]);
617
+ if (latestUserMessageIndex !== undefined) {
618
+ for (const [messageId, messageIndex] of firstUserMessageIndexes) {
619
+ if (messageIndex < latestUserMessageIndex) {
620
+ continue;
621
+ }
622
+ if (isInternalOpenCodeUserMessageId({
623
+ events,
624
+ sessionId,
625
+ messageId,
626
+ upToIndex,
627
+ })) {
628
+ turnParentMessageIds.add(messageId);
629
+ }
630
+ }
631
+ }
584
632
  const assistantMessageIds = new Set();
585
633
  for (let i = 0; i < end; i++) {
586
634
  const entry = events[i];
@@ -598,7 +646,7 @@ export function getAssistantMessageIdsForLatestUserTurn({ events, sessionId, upT
598
646
  if (!isUserFacingAssistantMessage(msg)) {
599
647
  continue;
600
648
  }
601
- if (msg.parentID === latestUserMessage.id) {
649
+ if (turnParentMessageIds.has(msg.parentID)) {
602
650
  assistantMessageIds.add(msg.id);
603
651
  }
604
652
  }
@@ -625,12 +673,12 @@ export function didLatestUserTurnUseSleepTool({ events, sessionId, upToIndex, })
625
673
  return false;
626
674
  }
627
675
  export function getLatestAssistantMessageIdForLatestUserTurn({ events, sessionId, upToIndex, }) {
628
- const latestUserMessage = getLatestUserMessage({
676
+ const assistantMessageIds = getAssistantMessageIdsForLatestUserTurn({
629
677
  events,
630
678
  sessionId,
631
679
  upToIndex,
632
680
  });
633
- if (!latestUserMessage) {
681
+ if (assistantMessageIds.size === 0) {
634
682
  return undefined;
635
683
  }
636
684
  const end = upToIndex ?? events.length - 1;
@@ -651,7 +699,7 @@ export function getLatestAssistantMessageIdForLatestUserTurn({ events, sessionId
651
699
  if (!isUserFacingAssistantMessage(info)) {
652
700
  continue;
653
701
  }
654
- if (info.parentID !== latestUserMessage.id) {
702
+ if (!assistantMessageIds.has(info.id)) {
655
703
  continue;
656
704
  }
657
705
  if (!latestAssistantMessage) {
@@ -476,6 +476,229 @@ describe('session-concurrent-messages-serialized', () => {
476
476
  expect(message.id).toBe(latestAssistantMessageId);
477
477
  });
478
478
  });
479
+ describe('ignored plugin notices do not steal the current Discord turn', () => {
480
+ const sessionId = 'ses_notice';
481
+ const userMessageId = 'msg_user';
482
+ const replyMessageId = 'msg_asst';
483
+ const noticeMessageId = 'msg_notice';
484
+ const replyAfterNoticeMessageId = 'msg_asst_after_notice';
485
+ const events = [
486
+ eventEntry({
487
+ type: 'message.updated',
488
+ properties: {
489
+ sessionID: sessionId,
490
+ info: {
491
+ id: userMessageId,
492
+ sessionID: sessionId,
493
+ role: 'user',
494
+ time: { created: 1 },
495
+ agent: 'build',
496
+ model: { providerID: 'test-provider', modelID: 'reply-model' },
497
+ },
498
+ },
499
+ }),
500
+ eventEntry({
501
+ type: 'message.part.updated',
502
+ properties: {
503
+ sessionID: sessionId,
504
+ part: {
505
+ id: 'prt_prompt',
506
+ sessionID: sessionId,
507
+ messageID: userMessageId,
508
+ type: 'text',
509
+ text: 'see how we display in discord tool calls',
510
+ },
511
+ },
512
+ }),
513
+ eventEntry({
514
+ type: 'message.part.updated',
515
+ properties: {
516
+ sessionID: sessionId,
517
+ part: {
518
+ id: 'prt_branch',
519
+ sessionID: sessionId,
520
+ messageID: userMessageId,
521
+ type: 'text',
522
+ text: '\n[current git branch is main]\n',
523
+ synthetic: true,
524
+ },
525
+ },
526
+ }),
527
+ eventEntry({
528
+ type: 'message.updated',
529
+ properties: {
530
+ sessionID: sessionId,
531
+ info: {
532
+ id: replyMessageId,
533
+ sessionID: sessionId,
534
+ role: 'assistant',
535
+ parentID: userMessageId,
536
+ time: { created: 2 },
537
+ modelID: 'reply-model',
538
+ providerID: 'test-provider',
539
+ mode: 'build',
540
+ agent: 'build',
541
+ path: { cwd: '/test', root: '/test' },
542
+ cost: 1,
543
+ tokens: {
544
+ input: 10,
545
+ output: 1,
546
+ reasoning: 0,
547
+ cache: { read: 0, write: 0 },
548
+ },
549
+ },
550
+ },
551
+ }),
552
+ eventEntry({
553
+ type: 'message.updated',
554
+ properties: {
555
+ sessionID: sessionId,
556
+ info: {
557
+ id: noticeMessageId,
558
+ sessionID: sessionId,
559
+ role: 'user',
560
+ time: { created: 3 },
561
+ agent: 'build',
562
+ model: { providerID: 'subrouter', modelID: 'build' },
563
+ },
564
+ },
565
+ }),
566
+ eventEntry({
567
+ type: 'message.part.updated',
568
+ properties: {
569
+ sessionID: sessionId,
570
+ part: {
571
+ id: 'prt_notice',
572
+ sessionID: sessionId,
573
+ messageID: noticeMessageId,
574
+ type: 'text',
575
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
576
+ ignored: true,
577
+ },
578
+ },
579
+ }),
580
+ eventEntry({
581
+ type: 'message.updated',
582
+ properties: {
583
+ sessionID: sessionId,
584
+ info: {
585
+ id: replyAfterNoticeMessageId,
586
+ sessionID: sessionId,
587
+ role: 'assistant',
588
+ parentID: noticeMessageId,
589
+ time: { created: 4, completed: 5 },
590
+ modelID: 'build',
591
+ providerID: 'subrouter',
592
+ mode: 'build',
593
+ agent: 'build',
594
+ path: { cwd: '/test', root: '/test' },
595
+ cost: 1,
596
+ tokens: {
597
+ input: 10,
598
+ output: 1,
599
+ reasoning: 0,
600
+ cache: { read: 0, write: 0 },
601
+ },
602
+ finish: 'stop',
603
+ },
604
+ },
605
+ }),
606
+ ];
607
+ test('keeps the real user prompt as the latest turn', () => {
608
+ expect(getLatestUserMessage({ events, sessionId })?.id).toBe(userMessageId);
609
+ expect(getAssistantMessageIdsForLatestUserTurn({
610
+ events,
611
+ sessionId,
612
+ })).toEqual(new Set([replyMessageId, replyAfterNoticeMessageId]));
613
+ expect(getLatestAssistantMessageIdForLatestUserTurn({
614
+ events,
615
+ sessionId,
616
+ })).toBe(replyAfterNoticeMessageId);
617
+ expect(doesLatestUserTurnHaveNaturalCompletion({
618
+ events,
619
+ sessionId,
620
+ })).toBe(true);
621
+ });
622
+ test('keeps a file prompt with an ignored notice as the latest turn', () => {
623
+ const fileUserId = 'msg_file_user';
624
+ const fileAssistantId = 'msg_file_asst';
625
+ const fileEvents = [
626
+ eventEntry({
627
+ type: 'message.updated',
628
+ properties: {
629
+ sessionID: sessionId,
630
+ info: {
631
+ id: fileUserId,
632
+ sessionID: sessionId,
633
+ role: 'user',
634
+ time: { created: 4 },
635
+ agent: 'build',
636
+ model: { providerID: 'test-provider', modelID: 'reply-model' },
637
+ },
638
+ },
639
+ }),
640
+ eventEntry({
641
+ type: 'message.part.updated',
642
+ properties: {
643
+ sessionID: sessionId,
644
+ part: {
645
+ id: 'prt_file',
646
+ sessionID: sessionId,
647
+ messageID: fileUserId,
648
+ type: 'file',
649
+ mime: 'image/png',
650
+ filename: 'shot.png',
651
+ url: 'https://example.com/shot.png',
652
+ },
653
+ },
654
+ }),
655
+ eventEntry({
656
+ type: 'message.part.updated',
657
+ properties: {
658
+ sessionID: sessionId,
659
+ part: {
660
+ id: 'prt_file_notice',
661
+ sessionID: sessionId,
662
+ messageID: fileUserId,
663
+ type: 'text',
664
+ text: 'Subrouter: Using openai/gpt-5.6-sol because xai/grok-4.6 is rate limited.',
665
+ ignored: true,
666
+ },
667
+ },
668
+ }),
669
+ eventEntry({
670
+ type: 'message.updated',
671
+ properties: {
672
+ sessionID: sessionId,
673
+ info: {
674
+ id: fileAssistantId,
675
+ sessionID: sessionId,
676
+ role: 'assistant',
677
+ parentID: fileUserId,
678
+ time: { created: 5 },
679
+ modelID: 'reply-model',
680
+ providerID: 'test-provider',
681
+ mode: 'build',
682
+ agent: 'build',
683
+ path: { cwd: '/test', root: '/test' },
684
+ cost: 1,
685
+ tokens: {
686
+ input: 10,
687
+ output: 1,
688
+ reasoning: 0,
689
+ cache: { read: 0, write: 0 },
690
+ },
691
+ },
692
+ },
693
+ }),
694
+ ];
695
+ expect(getLatestUserMessage({ events: fileEvents, sessionId })?.id).toBe(fileUserId);
696
+ expect(getAssistantMessageIdsForLatestUserTurn({
697
+ events: fileEvents,
698
+ sessionId,
699
+ })).toEqual(new Set([fileAssistantId]));
700
+ });
701
+ });
479
702
  describe('session-tool-call-noisy-stream', () => {
480
703
  const events = loadFixture('session-tool-call-noisy-stream.jsonl');
481
704
  const sessionId = getSessionId(events);
@@ -94,6 +94,10 @@ export function enqueueItem(threadId, item) {
94
94
  queueItems: [...t.queueItems, item],
95
95
  }));
96
96
  }
97
+ export function replaceQueueItems(threadId, items) {
98
+ ensureThread(threadId);
99
+ updateThread(threadId, (t) => ({ ...t, queueItems: items }));
100
+ }
97
101
  // Atomic dequeue: read + write in one setState call to prevent
98
102
  // a concurrent enqueue between read and write from losing items.
99
103
  export function dequeueItem(threadId) {