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.
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +84 -33
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli.js +2 -0
- package/dist/commands/queue.js +79 -31
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +76 -0
- package/dist/db.js +34 -0
- package/dist/db.test.js +96 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +9 -3
- package/dist/discord-command-registration.js +1 -1
- package/dist/guild-categories.test.js +225 -0
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/message-formatting.js +9 -16
- package/dist/message-formatting.test.js +91 -2
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +53 -5
- package/dist/session-handler/event-stream-state.test.js +223 -0
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +314 -73
- package/dist/system-message.js +55 -13
- package/dist/system-message.test.js +50 -13
- package/dist/task-id-plugin.js +16 -0
- package/package.json +5 -5
- package/skills/strada/SKILL.md +61 -232
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +123 -34
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli.ts +2 -0
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +106 -40
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +114 -0
- package/src/db.test.ts +112 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +11 -2
- package/src/discord-command-registration.ts +3 -1
- package/src/guild-categories.test.ts +260 -0
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/message-formatting.test.ts +105 -1
- package/src/message-formatting.ts +7 -12
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +226 -0
- package/src/session-handler/event-stream-state.ts +57 -5
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +380 -81
- package/src/system-message.test.ts +50 -13
- package/src/system-message.ts +58 -12
- 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
|
|
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
|
|
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
|
|
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
|
|
676
|
+
const assistantMessageIds = getAssistantMessageIdsForLatestUserTurn({
|
|
629
677
|
events,
|
|
630
678
|
sessionId,
|
|
631
679
|
upToIndex,
|
|
632
680
|
});
|
|
633
|
-
if (
|
|
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.
|
|
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) {
|