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
@@ -0,0 +1,225 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { afterEach, beforeEach, expect, test } from 'vitest';
4
+ import { ChannelType, Client, Events, GatewayIntentBits } from 'discord.js';
5
+ import { DigitalDiscord } from 'discord-digital-twin/src';
6
+ import { setDataDir } from './config.js';
7
+ import { closeDb } from './db.js';
8
+ import { createDefaultKimakiChannel, createProjectChannels, ensureKimakiCategory, } from './channel-management.js';
9
+ import { getGuildCategories, initDatabase, setChannelDirectory, } from './database.js';
10
+ const guildId = '100000000000000111';
11
+ const otherGuildId = '100000000000000222';
12
+ let directory;
13
+ let discord;
14
+ let client;
15
+ async function startDiscord({ dataDir }) {
16
+ discord = new DigitalDiscord({
17
+ dbUrl: `file:${path.join(dataDir, 'discord.db')}`,
18
+ users: [{ id: '100000000000000555', username: 'server-owner' }],
19
+ guilds: [
20
+ { id: guildId, name: 'Studio', ownerId: '100000000000000555' },
21
+ { id: otherGuildId, name: 'Office', ownerId: '100000000000000555' },
22
+ ],
23
+ });
24
+ await discord.start();
25
+ client = new Client({
26
+ intents: [GatewayIntentBits.Guilds],
27
+ rest: { api: discord.restUrl, version: '10' },
28
+ });
29
+ const ready = new Promise((resolve) => client.once(Events.ClientReady, () => resolve()));
30
+ await client.login(discord.botToken);
31
+ await ready;
32
+ }
33
+ async function openDataDir() {
34
+ const root = path.resolve('tmp/guild-categories');
35
+ fs.mkdirSync(root, { recursive: true });
36
+ directory = fs.mkdtempSync(path.join(root, 'run-'));
37
+ setDataDir(directory);
38
+ await closeDb();
39
+ await initDatabase();
40
+ }
41
+ beforeEach(async () => {
42
+ await openDataDir();
43
+ await startDiscord({ dataDir: directory });
44
+ });
45
+ afterEach(async () => {
46
+ await client?.destroy();
47
+ await discord?.stop();
48
+ await closeDb();
49
+ fs.rmSync(directory, { recursive: true, force: true });
50
+ });
51
+ function guild() {
52
+ return client.guilds.cache.get(guildId);
53
+ }
54
+ test('first project creates a Kimaki group and stores its id', async () => {
55
+ const created = await createProjectChannels({
56
+ guild: guild(),
57
+ projectDirectory: path.join(directory, 'web'),
58
+ });
59
+ const channel = await guild().channels.fetch(created.textChannelId);
60
+ expect(channel?.parentId).toBeTruthy();
61
+ const category = await guild().channels.fetch(channel.parentId);
62
+ expect(category?.name).toBe('Kimaki');
63
+ expect(await getGuildCategories(guildId)).toEqual({
64
+ guild_id: guildId,
65
+ category_id: category.id,
66
+ audio_category_id: null,
67
+ });
68
+ });
69
+ test('renamed group still receives new project channels', async () => {
70
+ const first = await createProjectChannels({
71
+ guild: guild(),
72
+ projectDirectory: path.join(directory, 'web'),
73
+ });
74
+ const firstChannel = await guild().channels.fetch(first.textChannelId);
75
+ const category = await guild().channels.fetch(firstChannel.parentId);
76
+ await category.setName('laptop');
77
+ const second = await createProjectChannels({
78
+ guild: guild(),
79
+ projectDirectory: path.join(directory, 'api'),
80
+ });
81
+ const secondChannel = await guild().channels.fetch(second.textChannelId);
82
+ expect(secondChannel?.parentId).toBe(firstChannel?.parentId);
83
+ expect((await guild().channels.fetch(secondChannel.parentId))?.name).toBe('laptop');
84
+ });
85
+ test('a second machine creates its own Kimaki group', async () => {
86
+ const first = await createProjectChannels({
87
+ guild: guild(),
88
+ projectDirectory: path.join(directory, 'web'),
89
+ });
90
+ const firstParent = (await guild().channels.fetch(first.textChannelId))
91
+ ?.parentId;
92
+ await closeDb();
93
+ await openDataDir();
94
+ const second = await createProjectChannels({
95
+ guild: guild(),
96
+ projectDirectory: path.join(directory, 'api'),
97
+ });
98
+ const secondParent = (await guild().channels.fetch(second.textChannelId))
99
+ ?.parentId;
100
+ expect(secondParent).toBeTruthy();
101
+ expect(secondParent).not.toBe(firstParent);
102
+ const categories = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory);
103
+ expect(categories.map((channel) => channel.name).sort()).toEqual([
104
+ 'Kimaki',
105
+ 'Kimaki',
106
+ ]);
107
+ });
108
+ test('existing project channels adopt their parent without creating a new group', async () => {
109
+ const category = await guild().channels.create({
110
+ name: 'studio',
111
+ type: ChannelType.GuildCategory,
112
+ });
113
+ const text = await guild().channels.create({
114
+ name: 'web',
115
+ type: ChannelType.GuildText,
116
+ parent: category,
117
+ });
118
+ await setChannelDirectory({
119
+ channelId: text.id,
120
+ directory: path.join(directory, 'web'),
121
+ channelType: 'text',
122
+ guildId,
123
+ });
124
+ const resolved = await ensureKimakiCategory(guild());
125
+ expect(resolved.id).toBe(category.id);
126
+ expect(await getGuildCategories(guildId)).toMatchObject({
127
+ guild_id: guildId,
128
+ category_id: category.id,
129
+ });
130
+ const categoryCount = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory).length;
131
+ expect(categoryCount).toBe(1);
132
+ });
133
+ test('a second machine gets its own default kimaki channel', async () => {
134
+ const first = await createDefaultKimakiChannel({
135
+ guild: guild(),
136
+ appId: discord.botUserId,
137
+ isGatewayMode: true,
138
+ });
139
+ expect(first).toBeTruthy();
140
+ await closeDb();
141
+ await openDataDir();
142
+ const second = await createDefaultKimakiChannel({
143
+ guild: guild(),
144
+ appId: discord.botUserId,
145
+ isGatewayMode: true,
146
+ });
147
+ expect(second).toBeTruthy();
148
+ expect(second.textChannelId).not.toBe(first.textChannelId);
149
+ expect(second.textChannel.parentId).not.toBe(first.textChannel.parentId);
150
+ });
151
+ test('legacy rows from another guild do not block adoption', async () => {
152
+ const otherGuild = client.guilds.cache.get(otherGuildId);
153
+ const foreignCategory = await otherGuild.channels.create({
154
+ name: 'foreign',
155
+ type: ChannelType.GuildCategory,
156
+ });
157
+ const foreignChannel = await otherGuild.channels.create({
158
+ name: 'web',
159
+ type: ChannelType.GuildText,
160
+ parent: foreignCategory,
161
+ });
162
+ const localCategory = await guild().channels.create({
163
+ name: 'studio',
164
+ type: ChannelType.GuildCategory,
165
+ });
166
+ const localChannel = await guild().channels.create({
167
+ name: 'api',
168
+ type: ChannelType.GuildText,
169
+ parent: localCategory,
170
+ });
171
+ await setChannelDirectory({
172
+ channelId: foreignChannel.id,
173
+ directory: path.join(directory, 'foreign'),
174
+ channelType: 'text',
175
+ });
176
+ await setChannelDirectory({
177
+ channelId: localChannel.id,
178
+ directory: path.join(directory, 'api'),
179
+ channelType: 'text',
180
+ });
181
+ const resolved = await ensureKimakiCategory(guild());
182
+ expect(resolved.id).toBe(localCategory.id);
183
+ });
184
+ test('a kimaki-prefixed project channel does not block the default channel', async () => {
185
+ await createProjectChannels({
186
+ guild: guild(),
187
+ projectDirectory: path.join(directory, 'kimaki-tools'),
188
+ });
189
+ const created = await createDefaultKimakiChannel({
190
+ guild: guild(),
191
+ appId: discord.botUserId,
192
+ isGatewayMode: true,
193
+ });
194
+ expect(created).toBeTruthy();
195
+ expect(created.channelName).toBe('kimaki');
196
+ });
197
+ test('concurrent first creates share one group', async () => {
198
+ const [first, second] = await Promise.all([
199
+ ensureKimakiCategory(guild()),
200
+ ensureKimakiCategory(guild()),
201
+ ]);
202
+ expect(first.id).toBe(second.id);
203
+ const categories = [...guild().channels.cache.values()].filter((channel) => channel.type === ChannelType.GuildCategory);
204
+ expect(categories).toHaveLength(1);
205
+ });
206
+ test('deleted group is replaced and SQLite is updated', async () => {
207
+ const first = await createProjectChannels({
208
+ guild: guild(),
209
+ projectDirectory: path.join(directory, 'web'),
210
+ });
211
+ const firstParent = (await guild().channels.fetch(first.textChannelId))
212
+ ?.parentId;
213
+ await (await guild().channels.fetch(firstParent)).delete();
214
+ const second = await createProjectChannels({
215
+ guild: guild(),
216
+ projectDirectory: path.join(directory, 'api'),
217
+ });
218
+ const secondParent = (await guild().channels.fetch(second.textChannelId))
219
+ ?.parentId;
220
+ expect(secondParent).toBeTruthy();
221
+ expect(secondParent).not.toBe(firstParent);
222
+ expect(await getGuildCategories(guildId)).toMatchObject({
223
+ category_id: secondParent,
224
+ });
225
+ });
@@ -28,7 +28,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
28
28
  import { handleFileUploadButton, handleFileUploadModalSubmit, } from './commands/file-upload.js';
29
29
  import { handleActionButton } from './commands/action-buttons.js';
30
30
  import { handleHtmlActionButton } from './html-actions.js';
31
- import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
31
+ import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, handleQueueRemoveButton, QUEUE_REMOVE_CUSTOM_ID_PREFIX, } from './commands/queue.js';
32
32
  import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
33
33
  import { handleUserCommand } from './commands/user-command.js';
34
34
  import { handleVerbosityCommand, handleVerbositySelectMenu, } from './commands/verbosity.js';
@@ -397,6 +397,10 @@ export function registerInteractionHandler({ discordClient, appId, }) {
397
397
  await handleActionButton(interaction);
398
398
  return;
399
399
  }
400
+ if (customId.startsWith(QUEUE_REMOVE_CUSTOM_ID_PREFIX)) {
401
+ await handleQueueRemoveButton(interaction);
402
+ return;
403
+ }
400
404
  if (customId.startsWith('html_action:')) {
401
405
  await handleHtmlActionButton(interaction);
402
406
  return;
@@ -11,6 +11,7 @@
11
11
  // - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
12
12
  // - file-edit-log: record edit/write/apply_patch files per session
13
13
  // - bash-tool-schema-plugin: add description and hasSideEffect to bash
14
+ // - task-id-plugin: drop invalid model-generated task resume IDs
14
15
  export { ipcToolsPlugin } from './ipc-tools-plugin.js';
15
16
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
16
17
  export { memoryOverviewPlugin } from './memory-overview-plugin.js';
@@ -29,3 +30,4 @@ export { injectionGuardInternal as injectionGuard } from 'opencode-injection-gua
29
30
  export { kimakiWorkspaceAdaptorPlugin } from './kimaki-workspace-adaptor.js';
30
31
  export { fileEditTrackerPlugin } from './file-edit-log.js';
31
32
  export { bashToolSchemaPlugin } from './bash-tool-schema-plugin.js';
33
+ export { taskIdPlugin } from './task-id-plugin.js';
@@ -1,5 +1,6 @@
1
1
  import { test, expect, describe } from 'vitest';
2
2
  import { condenseMemoryMd } from './condense-memory.js';
3
+ import { sanitizeTaskArguments } from './task-id-plugin.js';
3
4
  describe('condenseMemoryMd', () => {
4
5
  test('multiple headings with body content', () => {
5
6
  const content = [
@@ -96,3 +97,30 @@ describe('condenseMemoryMd', () => {
96
97
  `);
97
98
  });
98
99
  });
100
+ describe('sanitizeTaskArguments', () => {
101
+ test('removes a model-invented UUID task_id', () => {
102
+ const args = {
103
+ description: 'inspect repository',
104
+ prompt: 'Find the implementation',
105
+ subagent_type: 'explore',
106
+ task_id: 'ff120f9f-a28d-4d29-b691-b3a5f770759b',
107
+ };
108
+ sanitizeTaskArguments(args);
109
+ expect(args).toMatchInlineSnapshot(`
110
+ {
111
+ "description": "inspect repository",
112
+ "prompt": "Find the implementation",
113
+ "subagent_type": "explore",
114
+ }
115
+ `);
116
+ });
117
+ test('keeps an OpenCode session task_id for resume', () => {
118
+ const args = { task_id: 'ses_child' };
119
+ sanitizeTaskArguments(args);
120
+ expect(args).toMatchInlineSnapshot(`
121
+ {
122
+ "task_id": "ses_child",
123
+ }
124
+ `);
125
+ });
126
+ });
@@ -0,0 +1,163 @@
1
+ import { ChannelType, Routes } from 'discord.js';
2
+ import * as errore from 'errore';
3
+ import { DiscordApiError } from './errors.js';
4
+ import { isThreadChannelType } from './discord-utils.js';
5
+ export const DISCORD_THREAD_LIST_PAGE_SIZE = 100;
6
+ const ARCHIVED_THREAD_FETCH_LIMIT = 200;
7
+ class DiscordThreadListError extends errore.createTaggedError({
8
+ name: 'DiscordThreadListError',
9
+ message: 'Failed to list Discord threads for channel $channelId',
10
+ }) {
11
+ }
12
+ export function parseDiscordThreadPayload({ payload, parentId, guildId, }) {
13
+ if (!payload.id || !isThreadChannelType(payload.type ?? -1)) {
14
+ return null;
15
+ }
16
+ if (payload.parent_id && payload.parent_id !== parentId) {
17
+ return null;
18
+ }
19
+ const archived = Boolean(payload.thread_metadata?.archived);
20
+ return {
21
+ id: payload.id,
22
+ name: payload.name || payload.id,
23
+ parentId,
24
+ guildId: payload.guild_id || guildId,
25
+ archived,
26
+ archiveState: archived ? 'archived' : 'active',
27
+ lastMessageId: payload.last_message_id ?? null,
28
+ };
29
+ }
30
+ export function mergeDiscordChannelThreads({ threads, }) {
31
+ const byId = new Map();
32
+ for (const thread of threads) {
33
+ byId.set(thread.id, thread);
34
+ }
35
+ return [...byId.values()].sort((left, right) => {
36
+ if (left.archived !== right.archived) {
37
+ return left.archived ? 1 : -1;
38
+ }
39
+ const leftMessage = left.lastMessageId ?? '';
40
+ const rightMessage = right.lastMessageId ?? '';
41
+ if (leftMessage !== rightMessage) {
42
+ return rightMessage.localeCompare(leftMessage);
43
+ }
44
+ return left.name.localeCompare(right.name);
45
+ });
46
+ }
47
+ function parseThreadListPayload({ payload, parentId, guildId, }) {
48
+ if (!payload || typeof payload !== 'object') {
49
+ return { threads: [], hasMore: false };
50
+ }
51
+ const list = payload;
52
+ const threads = (list.threads ?? []).flatMap((thread) => {
53
+ const parsed = parseDiscordThreadPayload({
54
+ payload: thread,
55
+ parentId,
56
+ guildId,
57
+ });
58
+ return parsed ? [parsed] : [];
59
+ });
60
+ return {
61
+ threads,
62
+ hasMore: Boolean(list.has_more),
63
+ };
64
+ }
65
+ async function fetchDiscordJson({ rest, route, query, }) {
66
+ const result = await rest
67
+ .get(route, query ? { query } : undefined)
68
+ .catch((cause) => {
69
+ return new DiscordApiError({
70
+ status: String(Reflect.get(cause, 'status') ?? 'unknown'),
71
+ body: cause instanceof Error ? cause.message : String(cause),
72
+ cause,
73
+ });
74
+ });
75
+ return result;
76
+ }
77
+ async function listArchivedPublicThreads({ rest, channelId, guildId, }) {
78
+ const threads = [];
79
+ let before;
80
+ while (threads.length < ARCHIVED_THREAD_FETCH_LIMIT) {
81
+ const query = new URLSearchParams({
82
+ limit: String(DISCORD_THREAD_LIST_PAGE_SIZE),
83
+ });
84
+ if (before) {
85
+ query.set('before', before);
86
+ }
87
+ const payload = await fetchDiscordJson({
88
+ rest,
89
+ route: Routes.channelThreads(channelId, 'public'),
90
+ query,
91
+ });
92
+ if (payload instanceof DiscordApiError) {
93
+ return payload;
94
+ }
95
+ const page = parseThreadListPayload({
96
+ payload,
97
+ parentId: channelId,
98
+ guildId,
99
+ });
100
+ threads.push(...page.threads);
101
+ const lastThread = page.threads.at(-1);
102
+ if (!page.hasMore || !lastThread) {
103
+ break;
104
+ }
105
+ before = lastThread.id;
106
+ }
107
+ return threads;
108
+ }
109
+ export async function listDiscordChannelThreads({ rest, channelId, }) {
110
+ const channelPayload = await fetchDiscordJson({
111
+ rest,
112
+ route: Routes.channel(channelId),
113
+ });
114
+ if (channelPayload instanceof DiscordApiError) {
115
+ return { ok: false, error: channelPayload };
116
+ }
117
+ const channel = (channelPayload ?? {});
118
+ if (channel.type !== ChannelType.GuildText) {
119
+ return {
120
+ ok: false,
121
+ error: new DiscordThreadListError({
122
+ channelId,
123
+ cause: new Error('Channel is not a guild text channel'),
124
+ }),
125
+ };
126
+ }
127
+ const guildId = channel.guild_id;
128
+ if (!guildId) {
129
+ return {
130
+ ok: false,
131
+ error: new DiscordThreadListError({
132
+ channelId,
133
+ cause: new Error('Channel has no guild ID'),
134
+ }),
135
+ };
136
+ }
137
+ const activePayload = await fetchDiscordJson({
138
+ rest,
139
+ route: Routes.guildActiveThreads(guildId),
140
+ });
141
+ if (activePayload instanceof DiscordApiError) {
142
+ return { ok: false, error: activePayload };
143
+ }
144
+ const activeThreads = parseThreadListPayload({
145
+ payload: activePayload,
146
+ parentId: channelId,
147
+ guildId,
148
+ }).threads;
149
+ const archivedThreads = await listArchivedPublicThreads({
150
+ rest,
151
+ channelId,
152
+ guildId,
153
+ });
154
+ if (archivedThreads instanceof DiscordApiError) {
155
+ return { ok: false, error: archivedThreads };
156
+ }
157
+ return {
158
+ ok: true,
159
+ threads: mergeDiscordChannelThreads({
160
+ threads: [...activeThreads, ...archivedThreads],
161
+ }),
162
+ };
163
+ }
@@ -0,0 +1,80 @@
1
+ import { ChannelType } from 'discord.js';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { mergeDiscordChannelThreads, parseDiscordThreadPayload, } from './list-channel-threads.js';
4
+ describe('list-channel-threads', () => {
5
+ test('parses public threads and skips other channel types', () => {
6
+ const parsed = parseDiscordThreadPayload({
7
+ payload: {
8
+ id: 'thread-1',
9
+ name: 'Fix auth timeout',
10
+ type: ChannelType.PublicThread,
11
+ parent_id: 'channel-1',
12
+ guild_id: 'guild-1',
13
+ last_message_id: 'msg-9',
14
+ thread_metadata: { archived: false },
15
+ },
16
+ parentId: 'channel-1',
17
+ guildId: 'guild-1',
18
+ });
19
+ expect(parsed).toMatchInlineSnapshot(`
20
+ {
21
+ "archiveState": "active",
22
+ "archived": false,
23
+ "guildId": "guild-1",
24
+ "id": "thread-1",
25
+ "lastMessageId": "msg-9",
26
+ "name": "Fix auth timeout",
27
+ "parentId": "channel-1",
28
+ }
29
+ `);
30
+ expect(parseDiscordThreadPayload({
31
+ payload: {
32
+ id: 'not-a-thread',
33
+ name: 'website',
34
+ type: ChannelType.GuildText,
35
+ parent_id: 'category-1',
36
+ },
37
+ parentId: 'channel-1',
38
+ guildId: 'guild-1',
39
+ })).toBe(null);
40
+ });
41
+ test('keeps active threads first and drops duplicate ids', () => {
42
+ const merged = mergeDiscordChannelThreads({
43
+ threads: [
44
+ {
45
+ id: 'thread-old',
46
+ name: 'Old work',
47
+ parentId: 'channel-1',
48
+ guildId: 'guild-1',
49
+ archived: true,
50
+ archiveState: 'archived',
51
+ lastMessageId: '2',
52
+ },
53
+ {
54
+ id: 'thread-new',
55
+ name: 'New work',
56
+ parentId: 'channel-1',
57
+ guildId: 'guild-1',
58
+ archived: false,
59
+ archiveState: 'active',
60
+ lastMessageId: '1',
61
+ },
62
+ {
63
+ id: 'thread-new',
64
+ name: 'New work duplicate',
65
+ parentId: 'channel-1',
66
+ guildId: 'guild-1',
67
+ archived: false,
68
+ archiveState: 'active',
69
+ lastMessageId: '9',
70
+ },
71
+ ],
72
+ });
73
+ expect(merged.map((thread) => thread.id)).toMatchInlineSnapshot(`
74
+ [
75
+ "thread-new",
76
+ "thread-old",
77
+ ]
78
+ `);
79
+ });
80
+ });
@@ -195,31 +195,22 @@ export function shouldQuoteIntermediateTextPart({ part, isLastInTurn, nextToolNa
195
195
  return true;
196
196
  }
197
197
  export function planAssistantTurnFlush({ parts, mode, throughPartId, }) {
198
- const lastText = parts.filter(isNonEmptyTextPart).at(-1);
199
- const lastTextIndex = lastText
200
- ? parts.findLastIndex((part) => part.id === lastText.id)
201
- : -1;
202
198
  const throughIndex = throughPartId
203
199
  ? parts.findIndex((part) => part.id === throughPartId)
204
200
  : -1;
205
201
  const sendUntil = (() => {
206
- if (mode === 'final')
207
- return parts.length;
208
- if (mode === 'interactive') {
209
- if (throughIndex >= 0)
210
- return throughIndex + 1;
211
- return parts.length;
212
- }
213
- if (!lastText)
214
- return parts.length;
215
- // Hold unfinished last text and anything after it so tool order stays intact.
216
- if (!lastText.time?.end)
217
- return lastTextIndex;
202
+ if (mode === 'interactive' && throughIndex >= 0)
203
+ return throughIndex + 1;
218
204
  return parts.length;
219
205
  })();
220
206
  const sendParts = [];
221
207
  const holdParts = [];
222
208
  for (const [index, part] of parts.entries()) {
209
+ // Live tools must not wait for the current text part to end.
210
+ if (mode === 'progress' && part.type === 'text' && !part.time?.end) {
211
+ holdParts.push(part);
212
+ continue;
213
+ }
223
214
  if (index < sendUntil) {
224
215
  sendParts.push({
225
216
  part,
@@ -632,6 +623,8 @@ export function formatTaskToolTitle(part) {
632
623
  export function formatPart(part, prefix) {
633
624
  const pfx = prefix ? `${prefix} ⋅ ` : '';
634
625
  if (part.type === 'text') {
626
+ if (part.synthetic === true)
627
+ return '';
635
628
  const text = part.text?.trim();
636
629
  if (!text)
637
630
  return '';