kimaki 0.4.76 → 0.4.78
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/adapter-rest-boundary.test.js +34 -0
- package/dist/agent-model.e2e.test.js +2 -20
- package/dist/cli.js +50 -13
- package/dist/commands/channel-ref.js +16 -0
- package/dist/commands/diff.js +20 -85
- package/dist/commands/merge-worktree.js +5 -17
- package/dist/commands/new-worktree.js +5 -9
- package/dist/commands/permissions.js +77 -11
- package/dist/commands/resume.js +5 -9
- package/dist/commands/screenshare.js +295 -0
- package/dist/commands/session.js +6 -17
- package/dist/critique-utils.js +95 -0
- package/dist/diff-patch-plugin.js +314 -0
- package/dist/discord-bot.js +19 -14
- package/dist/discord-js-import-boundary.test.js +62 -0
- package/dist/discord-utils.js +44 -0
- package/dist/event-stream-real-capture.e2e.test.js +2 -20
- package/dist/gateway-proxy.e2e.test.js +2 -5
- package/dist/generated/cloudflare/browser.js +17 -0
- package/dist/generated/cloudflare/client.js +34 -0
- package/dist/generated/cloudflare/commonInputTypes.js +10 -0
- package/dist/generated/cloudflare/enums.js +48 -0
- package/dist/generated/cloudflare/internal/class.js +47 -0
- package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
- package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
- package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
- package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
- package/dist/generated/cloudflare/models/channel_agents.js +1 -0
- package/dist/generated/cloudflare/models/channel_directories.js +1 -0
- package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
- package/dist/generated/cloudflare/models/channel_models.js +1 -0
- package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
- package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
- package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
- package/dist/generated/cloudflare/models/global_models.js +1 -0
- package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
- package/dist/generated/cloudflare/models/part_messages.js +1 -0
- package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
- package/dist/generated/cloudflare/models/session_agents.js +1 -0
- package/dist/generated/cloudflare/models/session_events.js +1 -0
- package/dist/generated/cloudflare/models/session_models.js +1 -0
- package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
- package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
- package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
- package/dist/generated/cloudflare/models.js +1 -0
- package/dist/generated/node/browser.js +17 -0
- package/dist/generated/node/client.js +37 -0
- package/dist/generated/node/commonInputTypes.js +10 -0
- package/dist/generated/node/enums.js +48 -0
- package/dist/generated/node/internal/class.js +49 -0
- package/dist/generated/node/internal/prismaNamespace.js +252 -0
- package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
- package/dist/generated/node/models/bot_api_keys.js +1 -0
- package/dist/generated/node/models/bot_tokens.js +1 -0
- package/dist/generated/node/models/channel_agents.js +1 -0
- package/dist/generated/node/models/channel_directories.js +1 -0
- package/dist/generated/node/models/channel_mention_mode.js +1 -0
- package/dist/generated/node/models/channel_models.js +1 -0
- package/dist/generated/node/models/channel_verbosity.js +1 -0
- package/dist/generated/node/models/channel_worktrees.js +1 -0
- package/dist/generated/node/models/forum_sync_configs.js +1 -0
- package/dist/generated/node/models/global_models.js +1 -0
- package/dist/generated/node/models/ipc_requests.js +1 -0
- package/dist/generated/node/models/part_messages.js +1 -0
- package/dist/generated/node/models/scheduled_tasks.js +1 -0
- package/dist/generated/node/models/session_agents.js +1 -0
- package/dist/generated/node/models/session_events.js +1 -0
- package/dist/generated/node/models/session_models.js +1 -0
- package/dist/generated/node/models/session_start_sources.js +1 -0
- package/dist/generated/node/models/thread_sessions.js +1 -0
- package/dist/generated/node/models/thread_worktrees.js +1 -0
- package/dist/generated/node/models.js +1 -0
- package/dist/interaction-handler.js +10 -0
- package/dist/kimaki-digital-twin.e2e.test.js +2 -20
- package/dist/message-flags-boundary.test.js +54 -0
- package/dist/message-formatting.js +3 -62
- package/dist/onboarding-tutorial-plugin.js +1 -1
- package/dist/opencode-command.js +129 -0
- package/dist/opencode-command.test.js +48 -0
- package/dist/opencode-interrupt-plugin.js +19 -1
- package/dist/opencode-interrupt-plugin.test.js +0 -5
- package/dist/opencode-plugin-loading.e2e.test.js +9 -20
- package/dist/opencode-plugin.js +4 -4
- package/dist/opencode.js +150 -27
- package/dist/patch-text-parser.js +97 -0
- package/dist/platform/components-v2.js +20 -0
- package/dist/platform/discord-adapter.js +1440 -0
- package/dist/platform/discord-routes.js +31 -0
- package/dist/platform/message-flags.js +8 -0
- package/dist/platform/platform-value.js +41 -0
- package/dist/platform/slack-adapter.js +872 -0
- package/dist/platform/slack-markdown.js +169 -0
- package/dist/platform/types.js +4 -0
- package/dist/queue-advanced-e2e-setup.js +265 -0
- package/dist/queue-advanced-footer.e2e.test.js +173 -0
- package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
- package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
- package/dist/runtime-lifecycle.e2e.test.js +2 -20
- package/dist/session-handler/event-stream-state.js +5 -0
- package/dist/session-handler/event-stream-state.test.js +6 -2
- package/dist/session-handler/thread-session-runtime.js +32 -2
- package/dist/system-message.js +26 -23
- package/dist/test-utils.js +16 -0
- package/dist/thread-message-queue.e2e.test.js +2 -20
- package/dist/utils.js +3 -1
- package/dist/voice-message.e2e.test.js +2 -20
- package/dist/voice.js +122 -9
- package/dist/voice.test.js +17 -2
- package/dist/websockify.js +69 -0
- package/dist/worktree-lifecycle.e2e.test.js +308 -0
- package/package.json +4 -2
- package/skills/critique/SKILL.md +17 -0
- package/skills/egaki/SKILL.md +35 -0
- package/skills/event-sourcing-state/SKILL.md +252 -0
- package/skills/goke/SKILL.md +1 -0
- package/skills/npm-package/SKILL.md +21 -2
- package/skills/playwriter/SKILL.md +1 -1
- package/skills/x-articles/SKILL.md +554 -0
- package/src/agent-model.e2e.test.ts +4 -19
- package/src/cli.ts +60 -13
- package/src/commands/diff.ts +25 -99
- package/src/commands/merge-worktree.ts +5 -21
- package/src/commands/new-worktree.ts +5 -11
- package/src/commands/permissions.ts +100 -15
- package/src/commands/resume.ts +5 -12
- package/src/commands/screenshare.ts +354 -0
- package/src/commands/session.ts +6 -23
- package/src/critique-utils.ts +139 -0
- package/src/discord-bot.ts +20 -15
- package/src/discord-utils.ts +53 -0
- package/src/event-stream-real-capture.e2e.test.ts +4 -20
- package/src/gateway-proxy.e2e.test.ts +2 -5
- package/src/interaction-handler.ts +15 -0
- package/src/kimaki-digital-twin.e2e.test.ts +2 -21
- package/src/message-formatting.ts +3 -68
- package/src/onboarding-tutorial-plugin.ts +1 -1
- package/src/opencode-command.test.ts +70 -0
- package/src/opencode-command.ts +188 -0
- package/src/opencode-interrupt-plugin.test.ts +0 -5
- package/src/opencode-interrupt-plugin.ts +34 -1
- package/src/opencode-plugin-loading.e2e.test.ts +25 -35
- package/src/opencode-plugin.ts +5 -4
- package/src/opencode.ts +199 -32
- package/src/patch-text-parser.ts +107 -0
- package/src/queue-advanced-e2e-setup.ts +273 -0
- package/src/queue-advanced-footer.e2e.test.ts +211 -0
- package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
- package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
- package/src/runtime-lifecycle.e2e.test.ts +4 -19
- package/src/session-handler/event-stream-state.test.ts +6 -2
- package/src/session-handler/event-stream-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +45 -2
- package/src/system-message.ts +26 -23
- package/src/test-utils.ts +17 -0
- package/src/thread-message-queue.e2e.test.ts +2 -20
- package/src/utils.ts +3 -1
- package/src/voice-message.e2e.test.ts +3 -20
- package/src/voice.test.ts +26 -2
- package/src/voice.ts +147 -9
- package/src/websockify.ts +101 -0
- package/src/worktree-lifecycle.e2e.test.ts +391 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Slack markdown and Block Kit helpers for Kimaki's platform adapter.
|
|
2
|
+
// Converts Kimaki outgoing messages into Slack mrkdwn sections plus interactive
|
|
3
|
+
// action blocks for buttons and select menus.
|
|
4
|
+
const MAX_SECTION_TEXT_LENGTH = 3000;
|
|
5
|
+
const MAX_BLOCK_COUNT = 50;
|
|
6
|
+
const MAX_BUTTON_TEXT_LENGTH = 75;
|
|
7
|
+
const MAX_OPTION_TEXT_LENGTH = 75;
|
|
8
|
+
const MAX_PLACEHOLDER_TEXT_LENGTH = 150;
|
|
9
|
+
function truncate({ value, maxLength }) {
|
|
10
|
+
if (value.length <= maxLength) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
14
|
+
}
|
|
15
|
+
function normalizeMarkdown(markdown) {
|
|
16
|
+
return markdown
|
|
17
|
+
.replace(/^#{1,6}\s+(.+)$/gm, '*$1*')
|
|
18
|
+
.replace(/\*\*(.+?)\*\*/g, '*$1*')
|
|
19
|
+
.replace(/__(.+?)__/g, '*$1*')
|
|
20
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<$2|$1>');
|
|
21
|
+
}
|
|
22
|
+
function splitMrkdwnSections(markdown) {
|
|
23
|
+
const normalized = normalizeMarkdown(markdown).trim();
|
|
24
|
+
if (!normalized) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const paragraphs = normalized
|
|
28
|
+
.split(/\n{2,}/)
|
|
29
|
+
.map((paragraph) => {
|
|
30
|
+
return paragraph.trim();
|
|
31
|
+
})
|
|
32
|
+
.filter((paragraph) => {
|
|
33
|
+
return paragraph.length > 0;
|
|
34
|
+
});
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for (const paragraph of paragraphs) {
|
|
37
|
+
if (paragraph.length <= MAX_SECTION_TEXT_LENGTH) {
|
|
38
|
+
chunks.push(paragraph);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
let remaining = paragraph;
|
|
42
|
+
while (remaining.length > 0) {
|
|
43
|
+
const nextChunk = remaining.slice(0, MAX_SECTION_TEXT_LENGTH);
|
|
44
|
+
chunks.push(nextChunk);
|
|
45
|
+
remaining = remaining.slice(MAX_SECTION_TEXT_LENGTH).trimStart();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return chunks;
|
|
49
|
+
}
|
|
50
|
+
function toSlackButtonStyle(style) {
|
|
51
|
+
if (style === 'primary' || style === 'success') {
|
|
52
|
+
return 'primary';
|
|
53
|
+
}
|
|
54
|
+
if (style === 'danger') {
|
|
55
|
+
return 'danger';
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
function buildButtonElement(button) {
|
|
60
|
+
return {
|
|
61
|
+
type: 'button',
|
|
62
|
+
action_id: button.id,
|
|
63
|
+
text: {
|
|
64
|
+
type: 'plain_text',
|
|
65
|
+
text: truncate({
|
|
66
|
+
value: button.label,
|
|
67
|
+
maxLength: MAX_BUTTON_TEXT_LENGTH,
|
|
68
|
+
}),
|
|
69
|
+
emoji: true,
|
|
70
|
+
},
|
|
71
|
+
style: toSlackButtonStyle(button.style),
|
|
72
|
+
value: button.id,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function buildSelectMenuElement(selectMenu) {
|
|
76
|
+
const options = selectMenu.options.map((option) => {
|
|
77
|
+
return {
|
|
78
|
+
text: {
|
|
79
|
+
type: 'plain_text',
|
|
80
|
+
text: truncate({
|
|
81
|
+
value: option.label,
|
|
82
|
+
maxLength: MAX_OPTION_TEXT_LENGTH,
|
|
83
|
+
}),
|
|
84
|
+
emoji: true,
|
|
85
|
+
},
|
|
86
|
+
value: option.value,
|
|
87
|
+
description: option.description
|
|
88
|
+
? {
|
|
89
|
+
type: 'plain_text',
|
|
90
|
+
text: truncate({
|
|
91
|
+
value: option.description,
|
|
92
|
+
maxLength: MAX_OPTION_TEXT_LENGTH,
|
|
93
|
+
}),
|
|
94
|
+
emoji: true,
|
|
95
|
+
}
|
|
96
|
+
: undefined,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
const placeholder = {
|
|
100
|
+
type: 'plain_text',
|
|
101
|
+
text: truncate({
|
|
102
|
+
value: selectMenu.placeholder || 'Select an option',
|
|
103
|
+
maxLength: MAX_PLACEHOLDER_TEXT_LENGTH,
|
|
104
|
+
}),
|
|
105
|
+
emoji: true,
|
|
106
|
+
};
|
|
107
|
+
if ((selectMenu.maxValues ?? 1) > 1) {
|
|
108
|
+
return {
|
|
109
|
+
type: 'multi_static_select',
|
|
110
|
+
action_id: selectMenu.id,
|
|
111
|
+
placeholder,
|
|
112
|
+
options,
|
|
113
|
+
max_selected_items: selectMenu.maxValues,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
type: 'static_select',
|
|
118
|
+
action_id: selectMenu.id,
|
|
119
|
+
placeholder,
|
|
120
|
+
options,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function buildActionBlocks(message) {
|
|
124
|
+
const blocks = [];
|
|
125
|
+
const buttons = message.buttons || [];
|
|
126
|
+
if (buttons.length > 0) {
|
|
127
|
+
blocks.push({
|
|
128
|
+
type: 'actions',
|
|
129
|
+
elements: buttons.slice(0, 5).map((button) => {
|
|
130
|
+
return buildButtonElement(button);
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const selectMenus = [message.selectMenu, ...(message.selectMenus || [])].filter((value) => {
|
|
135
|
+
return Boolean(value);
|
|
136
|
+
});
|
|
137
|
+
for (const selectMenu of selectMenus) {
|
|
138
|
+
blocks.push({
|
|
139
|
+
type: 'actions',
|
|
140
|
+
elements: [buildSelectMenuElement(selectMenu)],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return blocks;
|
|
144
|
+
}
|
|
145
|
+
export function renderSlackMessage(message) {
|
|
146
|
+
const sections = splitMrkdwnSections(message.markdown);
|
|
147
|
+
const text = normalizeMarkdown(message.markdown).trim() || ' ';
|
|
148
|
+
const sectionBlocks = sections.map((section) => {
|
|
149
|
+
return {
|
|
150
|
+
type: 'section',
|
|
151
|
+
text: {
|
|
152
|
+
type: 'mrkdwn',
|
|
153
|
+
text: section,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
const actionBlocks = buildActionBlocks(message);
|
|
158
|
+
const blocks = [...sectionBlocks, ...actionBlocks].slice(0, MAX_BLOCK_COUNT);
|
|
159
|
+
if (blocks.length === 0) {
|
|
160
|
+
return {
|
|
161
|
+
text,
|
|
162
|
+
blocks: [],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
text,
|
|
167
|
+
blocks,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -299,6 +299,263 @@ export function createDeterministicMatchers() {
|
|
|
299
299
|
],
|
|
300
300
|
},
|
|
301
301
|
};
|
|
302
|
+
// Model responds with text + tool call, then after tool result the
|
|
303
|
+
// follow-up matcher responds with text. This creates two assistant messages:
|
|
304
|
+
// first with finish="tool-calls" + completed, second with finish="stop".
|
|
305
|
+
// Reproduces the bug where the first message gets no footer even though
|
|
306
|
+
// it completed normally (isAssistantMessageNaturalCompletion rejects
|
|
307
|
+
// finish="tool-calls").
|
|
308
|
+
const toolCallFooterMatcher = {
|
|
309
|
+
id: 'tool-call-footer',
|
|
310
|
+
priority: 108,
|
|
311
|
+
when: {
|
|
312
|
+
lastMessageRole: 'user',
|
|
313
|
+
latestUserTextIncludes: 'TOOL_CALL_FOOTER_MARKER',
|
|
314
|
+
},
|
|
315
|
+
then: {
|
|
316
|
+
parts: [
|
|
317
|
+
{ type: 'stream-start', warnings: [] },
|
|
318
|
+
{ type: 'text-start', id: 'tool-call-footer-text' },
|
|
319
|
+
{ type: 'text-delta', id: 'tool-call-footer-text', delta: 'running tool' },
|
|
320
|
+
{ type: 'text-end', id: 'tool-call-footer-text' },
|
|
321
|
+
{
|
|
322
|
+
type: 'tool-call',
|
|
323
|
+
toolCallId: 'tool-call-footer-bash',
|
|
324
|
+
toolName: 'bash',
|
|
325
|
+
input: JSON.stringify({
|
|
326
|
+
command: 'echo tool-call-footer-test',
|
|
327
|
+
description: 'Echo for footer test',
|
|
328
|
+
}),
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
type: 'finish',
|
|
332
|
+
finishReason: 'tool-calls',
|
|
333
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const toolCallFooterFollowupMatcher = {
|
|
339
|
+
id: 'tool-call-footer-followup',
|
|
340
|
+
priority: 109,
|
|
341
|
+
when: {
|
|
342
|
+
lastMessageRole: 'tool',
|
|
343
|
+
latestUserTextIncludes: 'TOOL_CALL_FOOTER_MARKER',
|
|
344
|
+
},
|
|
345
|
+
then: {
|
|
346
|
+
parts: [
|
|
347
|
+
{ type: 'stream-start', warnings: [] },
|
|
348
|
+
{ type: 'text-start', id: 'tool-call-footer-followup' },
|
|
349
|
+
{ type: 'text-delta', id: 'tool-call-footer-followup', delta: 'tool call completed' },
|
|
350
|
+
{ type: 'text-end', id: 'tool-call-footer-followup' },
|
|
351
|
+
{
|
|
352
|
+
type: 'finish',
|
|
353
|
+
finishReason: 'stop',
|
|
354
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
// Multi-step tool chain: model emits text + 3 parallel tool calls in one
|
|
360
|
+
// response (finish="tool-calls"). All tools complete, then the follow-up
|
|
361
|
+
// matcher responds with final text (finish="stop"). This creates 2 assistant
|
|
362
|
+
// messages — one with finish="tool-calls" + completed, one with finish="stop".
|
|
363
|
+
// With the naive fix (allowing tool-calls as natural completion), we'd get
|
|
364
|
+
// 2 footers. Only the final text response should get a footer.
|
|
365
|
+
const multiToolMatcher = {
|
|
366
|
+
id: 'multi-tool',
|
|
367
|
+
priority: 115,
|
|
368
|
+
when: {
|
|
369
|
+
lastMessageRole: 'user',
|
|
370
|
+
latestUserTextIncludes: 'MULTI_TOOL_FOOTER_MARKER',
|
|
371
|
+
},
|
|
372
|
+
then: {
|
|
373
|
+
parts: [
|
|
374
|
+
{ type: 'stream-start', warnings: [] },
|
|
375
|
+
{ type: 'text-start', id: 'multi-tool-text' },
|
|
376
|
+
{ type: 'text-delta', id: 'multi-tool-text', delta: 'investigating the issue' },
|
|
377
|
+
{ type: 'text-end', id: 'multi-tool-text' },
|
|
378
|
+
{
|
|
379
|
+
type: 'tool-call',
|
|
380
|
+
toolCallId: 'multi-tool-bash-1',
|
|
381
|
+
toolName: 'bash',
|
|
382
|
+
input: JSON.stringify({
|
|
383
|
+
command: 'echo search-done',
|
|
384
|
+
description: 'Search codebase',
|
|
385
|
+
}),
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
type: 'tool-call',
|
|
389
|
+
toolCallId: 'multi-tool-bash-2',
|
|
390
|
+
toolName: 'bash',
|
|
391
|
+
input: JSON.stringify({
|
|
392
|
+
command: 'echo read-done',
|
|
393
|
+
description: 'Read config file',
|
|
394
|
+
}),
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
type: 'tool-call',
|
|
398
|
+
toolCallId: 'multi-tool-bash-3',
|
|
399
|
+
toolName: 'bash',
|
|
400
|
+
input: JSON.stringify({
|
|
401
|
+
command: 'echo fix-done',
|
|
402
|
+
description: 'Apply fix',
|
|
403
|
+
}),
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
type: 'finish',
|
|
407
|
+
finishReason: 'tool-calls',
|
|
408
|
+
usage: { inputTokens: 10, outputTokens: 15, totalTokens: 25 },
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
const multiToolFollowupMatcher = {
|
|
414
|
+
id: 'multi-tool-followup',
|
|
415
|
+
priority: 114,
|
|
416
|
+
when: {
|
|
417
|
+
latestUserTextIncludes: 'MULTI_TOOL_FOOTER_MARKER',
|
|
418
|
+
rawPromptIncludes: 'investigating the issue',
|
|
419
|
+
},
|
|
420
|
+
then: {
|
|
421
|
+
parts: [
|
|
422
|
+
{ type: 'stream-start', warnings: [] },
|
|
423
|
+
{ type: 'text-start', id: 'multi-tool-followup-text' },
|
|
424
|
+
{ type: 'text-delta', id: 'multi-tool-followup-text', delta: 'all done, fixed 3 files' },
|
|
425
|
+
{ type: 'text-end', id: 'multi-tool-followup-text' },
|
|
426
|
+
{
|
|
427
|
+
type: 'finish',
|
|
428
|
+
finishReason: 'stop',
|
|
429
|
+
usage: { inputTokens: 30, outputTokens: 10, totalTokens: 40 },
|
|
430
|
+
},
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
// Multi-step sequential tool chain: 3 separate tool-call steps (each a
|
|
435
|
+
// separate assistant message with finish="tool-calls"), then a final text
|
|
436
|
+
// response. This creates 4 assistant messages total. Without proper
|
|
437
|
+
// deferred footer logic, each tool-call step would emit its own footer,
|
|
438
|
+
// producing 3 spurious footers before the real one.
|
|
439
|
+
//
|
|
440
|
+
// Flow: user → step1 (text + tool-call) → tool result →
|
|
441
|
+
// step2 (text + tool-call) → tool result →
|
|
442
|
+
// step3 (text + tool-call) → tool result →
|
|
443
|
+
// final text (finish="stop")
|
|
444
|
+
//
|
|
445
|
+
// Matcher priority ensures each step fires in order: the highest-priority
|
|
446
|
+
// matcher that matches wins, and each step's rawPromptIncludes check only
|
|
447
|
+
// matches once the previous step's output text is in the conversation.
|
|
448
|
+
const multiStepChainInitMatcher = {
|
|
449
|
+
id: 'multi-step-chain-init',
|
|
450
|
+
priority: 119,
|
|
451
|
+
when: {
|
|
452
|
+
lastMessageRole: 'user',
|
|
453
|
+
latestUserTextIncludes: 'MULTI_STEP_CHAIN_MARKER',
|
|
454
|
+
},
|
|
455
|
+
then: {
|
|
456
|
+
parts: [
|
|
457
|
+
{ type: 'stream-start', warnings: [] },
|
|
458
|
+
{ type: 'text-start', id: 'chain-step1-text' },
|
|
459
|
+
{ type: 'text-delta', id: 'chain-step1-text', delta: 'chain step 1: reading config' },
|
|
460
|
+
{ type: 'text-end', id: 'chain-step1-text' },
|
|
461
|
+
{
|
|
462
|
+
type: 'tool-call',
|
|
463
|
+
toolCallId: 'chain-step1-bash',
|
|
464
|
+
toolName: 'bash',
|
|
465
|
+
input: JSON.stringify({
|
|
466
|
+
command: 'echo chain-step-1-output',
|
|
467
|
+
description: 'Read config',
|
|
468
|
+
}),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
type: 'finish',
|
|
472
|
+
finishReason: 'tool-calls',
|
|
473
|
+
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
const multiStepChainStep2Matcher = {
|
|
479
|
+
id: 'multi-step-chain-step2',
|
|
480
|
+
priority: 120,
|
|
481
|
+
when: {
|
|
482
|
+
latestUserTextIncludes: 'MULTI_STEP_CHAIN_MARKER',
|
|
483
|
+
rawPromptIncludes: 'chain step 1: reading config',
|
|
484
|
+
},
|
|
485
|
+
then: {
|
|
486
|
+
parts: [
|
|
487
|
+
{ type: 'stream-start', warnings: [] },
|
|
488
|
+
{ type: 'text-start', id: 'chain-step2-text' },
|
|
489
|
+
{ type: 'text-delta', id: 'chain-step2-text', delta: 'chain step 2: analyzing results' },
|
|
490
|
+
{ type: 'text-end', id: 'chain-step2-text' },
|
|
491
|
+
{
|
|
492
|
+
type: 'tool-call',
|
|
493
|
+
toolCallId: 'chain-step2-bash',
|
|
494
|
+
toolName: 'bash',
|
|
495
|
+
input: JSON.stringify({
|
|
496
|
+
command: 'echo chain-step-2-output',
|
|
497
|
+
description: 'Analyze results',
|
|
498
|
+
}),
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
type: 'finish',
|
|
502
|
+
finishReason: 'tool-calls',
|
|
503
|
+
usage: { inputTokens: 15, outputTokens: 10, totalTokens: 25 },
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
const multiStepChainStep3Matcher = {
|
|
509
|
+
id: 'multi-step-chain-step3',
|
|
510
|
+
priority: 121,
|
|
511
|
+
when: {
|
|
512
|
+
latestUserTextIncludes: 'MULTI_STEP_CHAIN_MARKER',
|
|
513
|
+
rawPromptIncludes: 'chain step 2: analyzing results',
|
|
514
|
+
},
|
|
515
|
+
then: {
|
|
516
|
+
parts: [
|
|
517
|
+
{ type: 'stream-start', warnings: [] },
|
|
518
|
+
{ type: 'text-start', id: 'chain-step3-text' },
|
|
519
|
+
{ type: 'text-delta', id: 'chain-step3-text', delta: 'chain step 3: applying fix' },
|
|
520
|
+
{ type: 'text-end', id: 'chain-step3-text' },
|
|
521
|
+
{
|
|
522
|
+
type: 'tool-call',
|
|
523
|
+
toolCallId: 'chain-step3-bash',
|
|
524
|
+
toolName: 'bash',
|
|
525
|
+
input: JSON.stringify({
|
|
526
|
+
command: 'echo chain-step-3-output',
|
|
527
|
+
description: 'Apply fix',
|
|
528
|
+
}),
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
type: 'finish',
|
|
532
|
+
finishReason: 'tool-calls',
|
|
533
|
+
usage: { inputTokens: 25, outputTokens: 10, totalTokens: 35 },
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
const multiStepChainFinalMatcher = {
|
|
539
|
+
id: 'multi-step-chain-final',
|
|
540
|
+
priority: 122,
|
|
541
|
+
when: {
|
|
542
|
+
latestUserTextIncludes: 'MULTI_STEP_CHAIN_MARKER',
|
|
543
|
+
rawPromptIncludes: 'chain step 3: applying fix',
|
|
544
|
+
},
|
|
545
|
+
then: {
|
|
546
|
+
parts: [
|
|
547
|
+
{ type: 'stream-start', warnings: [] },
|
|
548
|
+
{ type: 'text-start', id: 'chain-final-text' },
|
|
549
|
+
{ type: 'text-delta', id: 'chain-final-text', delta: 'chain complete: all 3 steps done' },
|
|
550
|
+
{ type: 'text-end', id: 'chain-final-text' },
|
|
551
|
+
{
|
|
552
|
+
type: 'finish',
|
|
553
|
+
finishReason: 'stop',
|
|
554
|
+
usage: { inputTokens: 35, outputTokens: 5, totalTokens: 40 },
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
};
|
|
302
559
|
return [
|
|
303
560
|
slowAbortMatcher,
|
|
304
561
|
typingRepulseMatcher,
|
|
@@ -307,7 +564,15 @@ export function createDeterministicMatchers() {
|
|
|
307
564
|
questionToolMatcher,
|
|
308
565
|
permissionTypingMatcher,
|
|
309
566
|
permissionTypingFollowupMatcher,
|
|
567
|
+
multiToolMatcher,
|
|
568
|
+
multiToolFollowupMatcher,
|
|
569
|
+
multiStepChainInitMatcher,
|
|
570
|
+
multiStepChainStep2Matcher,
|
|
571
|
+
multiStepChainStep3Matcher,
|
|
572
|
+
multiStepChainFinalMatcher,
|
|
310
573
|
raceFinalReplyMatcher,
|
|
574
|
+
toolCallFooterMatcher,
|
|
575
|
+
toolCallFooterFollowupMatcher,
|
|
311
576
|
toolFollowupMatcher,
|
|
312
577
|
userReplyMatcher,
|
|
313
578
|
];
|
|
@@ -296,4 +296,177 @@ e2eTest('queue advanced: footer emission', () => {
|
|
|
296
296
|
});
|
|
297
297
|
expect(footerBeforeReply).toBe(false);
|
|
298
298
|
}, 15_000);
|
|
299
|
+
test('tool-call assistant message gets footer when it completes normally', async () => {
|
|
300
|
+
// Reproduces the bug: model responds with text + tool call,
|
|
301
|
+
// finish="tool-calls", message gets completed timestamp. Then the tool
|
|
302
|
+
// result triggers a follow-up text response in a second assistant message.
|
|
303
|
+
// The second message gets a footer, but the first (tool-call) message
|
|
304
|
+
// should ALSO get a footer since it completed normally.
|
|
305
|
+
// This matches the real-world scenario where an agent calls a bash tool
|
|
306
|
+
// (e.g. `kimaki send`) and then follows up with a summary text.
|
|
307
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
308
|
+
content: 'TOOL_CALL_FOOTER_MARKER',
|
|
309
|
+
});
|
|
310
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
311
|
+
timeout: 4_000,
|
|
312
|
+
predicate: (t) => {
|
|
313
|
+
return t.name === 'TOOL_CALL_FOOTER_MARKER';
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const th = ctx.discord.thread(thread.id);
|
|
317
|
+
// Wait for the follow-up text response after tool completion.
|
|
318
|
+
// The tool call completes and the model follows up with a second
|
|
319
|
+
// assistant message containing text.
|
|
320
|
+
await waitForBotReplyAfterUserMessage({
|
|
321
|
+
discord: ctx.discord,
|
|
322
|
+
threadId: thread.id,
|
|
323
|
+
userId: TEST_USER_ID,
|
|
324
|
+
userMessageIncludes: 'TOOL_CALL_FOOTER_MARKER',
|
|
325
|
+
timeout: 4_000,
|
|
326
|
+
});
|
|
327
|
+
// Wait for at least one footer to appear
|
|
328
|
+
await waitForFooterMessage({
|
|
329
|
+
discord: ctx.discord,
|
|
330
|
+
threadId: thread.id,
|
|
331
|
+
timeout: 4_000,
|
|
332
|
+
});
|
|
333
|
+
// Poll until both footers have arrived — the first footer (after the
|
|
334
|
+
// tool-call step) and the second (after the text follow-up) are emitted
|
|
335
|
+
// by sequential handleNaturalAssistantCompletion calls but the second
|
|
336
|
+
// may not have hit the Discord thread by the time we first check.
|
|
337
|
+
const deadline = Date.now() + 4_000;
|
|
338
|
+
let footerCount = 0;
|
|
339
|
+
while (Date.now() < deadline) {
|
|
340
|
+
const msgs = await th.getMessages();
|
|
341
|
+
footerCount = msgs.filter((m) => {
|
|
342
|
+
return m.author.id === ctx.discord.botUserId
|
|
343
|
+
&& m.content.startsWith('*')
|
|
344
|
+
&& m.content.includes('⋅');
|
|
345
|
+
}).length;
|
|
346
|
+
if (footerCount >= 2) {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
await new Promise((resolve) => {
|
|
350
|
+
setTimeout(resolve, 100);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
354
|
+
"--- from: user (queue-advanced-tester)
|
|
355
|
+
TOOL_CALL_FOOTER_MARKER
|
|
356
|
+
--- from: assistant (TestBot)
|
|
357
|
+
⬥ running tool
|
|
358
|
+
⬥ ok
|
|
359
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
360
|
+
`);
|
|
361
|
+
// Only ONE footer at the end — the tool-call step's footer is NOT
|
|
362
|
+
// emitted mid-turn. The final text follow-up gets the footer.
|
|
363
|
+
expect(footerCount).toBe(1);
|
|
364
|
+
}, 10_000);
|
|
365
|
+
test('multi-step tool chain should only have one footer at the end', async () => {
|
|
366
|
+
// Model does 3 sequential tool calls (each a separate assistant message
|
|
367
|
+
// with finish="tool-calls") then a final text response. Only the final
|
|
368
|
+
// text response should get a footer — intermediate tool-call steps
|
|
369
|
+
// should NOT get footers since they're mid-turn work.
|
|
370
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
371
|
+
content: 'MULTI_TOOL_FOOTER_MARKER',
|
|
372
|
+
});
|
|
373
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
374
|
+
timeout: 4_000,
|
|
375
|
+
predicate: (t) => {
|
|
376
|
+
return t.name === 'MULTI_TOOL_FOOTER_MARKER';
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const th = ctx.discord.thread(thread.id);
|
|
380
|
+
// Wait for the final text response after all 3 tool steps
|
|
381
|
+
await waitForBotMessageContaining({
|
|
382
|
+
discord: ctx.discord,
|
|
383
|
+
threadId: thread.id,
|
|
384
|
+
userId: TEST_USER_ID,
|
|
385
|
+
text: 'all done, fixed 3 files',
|
|
386
|
+
timeout: 4_000,
|
|
387
|
+
});
|
|
388
|
+
// Wait for the footer after the final response
|
|
389
|
+
await waitForFooterMessage({
|
|
390
|
+
discord: ctx.discord,
|
|
391
|
+
threadId: thread.id,
|
|
392
|
+
timeout: 4_000,
|
|
393
|
+
});
|
|
394
|
+
// Give any spurious extra footers time to arrive
|
|
395
|
+
await new Promise((resolve) => {
|
|
396
|
+
setTimeout(resolve, 500);
|
|
397
|
+
});
|
|
398
|
+
const messages = await th.getMessages();
|
|
399
|
+
const footerCount = messages.filter((m) => {
|
|
400
|
+
return m.author.id === ctx.discord.botUserId
|
|
401
|
+
&& m.content.startsWith('*')
|
|
402
|
+
&& m.content.includes('⋅');
|
|
403
|
+
}).length;
|
|
404
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
405
|
+
"--- from: user (queue-advanced-tester)
|
|
406
|
+
MULTI_TOOL_FOOTER_MARKER
|
|
407
|
+
--- from: assistant (TestBot)
|
|
408
|
+
⬥ investigating the issue
|
|
409
|
+
⬥ all done, fixed 3 files
|
|
410
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
411
|
+
`);
|
|
412
|
+
// Only ONE footer should appear — after the final text response.
|
|
413
|
+
// Intermediate tool-call steps should NOT get footers.
|
|
414
|
+
expect(footerCount).toBe(1);
|
|
415
|
+
}, 10_000);
|
|
416
|
+
test('3 sequential tool-call steps produce exactly 1 footer, not 3', async () => {
|
|
417
|
+
// This is the most obvious reproduction of the multi-footer bug:
|
|
418
|
+
// the model runs 3 sequential tool-call steps (each a SEPARATE
|
|
419
|
+
// assistant message with finish="tool-calls"), then a final text.
|
|
420
|
+
// With a naive fix that treats tool-calls as natural completions,
|
|
421
|
+
// you'd see 4 footers (one per assistant message). Only the final
|
|
422
|
+
// text response should produce a footer.
|
|
423
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
424
|
+
content: 'MULTI_STEP_CHAIN_MARKER',
|
|
425
|
+
});
|
|
426
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
427
|
+
timeout: 4_000,
|
|
428
|
+
predicate: (t) => {
|
|
429
|
+
return t.name === 'MULTI_STEP_CHAIN_MARKER';
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
const th = ctx.discord.thread(thread.id);
|
|
433
|
+
// Wait for the final text after all 3 sequential tool steps
|
|
434
|
+
await waitForBotMessageContaining({
|
|
435
|
+
discord: ctx.discord,
|
|
436
|
+
threadId: thread.id,
|
|
437
|
+
userId: TEST_USER_ID,
|
|
438
|
+
text: 'chain complete: all 3 steps done',
|
|
439
|
+
timeout: 8_000,
|
|
440
|
+
});
|
|
441
|
+
// Wait for footer
|
|
442
|
+
await waitForFooterMessage({
|
|
443
|
+
discord: ctx.discord,
|
|
444
|
+
threadId: thread.id,
|
|
445
|
+
timeout: 4_000,
|
|
446
|
+
});
|
|
447
|
+
// Give any spurious extra footers time to arrive
|
|
448
|
+
await new Promise((resolve) => {
|
|
449
|
+
setTimeout(resolve, 500);
|
|
450
|
+
});
|
|
451
|
+
const messages = await th.getMessages();
|
|
452
|
+
const footerCount = messages.filter((m) => {
|
|
453
|
+
return m.author.id === ctx.discord.botUserId
|
|
454
|
+
&& m.content.startsWith('*')
|
|
455
|
+
&& m.content.includes('⋅');
|
|
456
|
+
}).length;
|
|
457
|
+
expect(await th.text()).toMatchInlineSnapshot(`
|
|
458
|
+
"--- from: user (queue-advanced-tester)
|
|
459
|
+
MULTI_STEP_CHAIN_MARKER
|
|
460
|
+
--- from: assistant (TestBot)
|
|
461
|
+
⬥ chain step 1: reading config
|
|
462
|
+
⬥ chain step 2: analyzing results
|
|
463
|
+
⬥ chain step 3: applying fix
|
|
464
|
+
⬥ chain complete: all 3 steps done
|
|
465
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
466
|
+
`);
|
|
467
|
+
// The critical assertion: only 1 footer at the very end.
|
|
468
|
+
// With the naive "allow tool-calls as natural completion" fix,
|
|
469
|
+
// this would be 4 (one per assistant message). We want 1.
|
|
470
|
+
expect(footerCount).toBe(1);
|
|
471
|
+
}, 15_000);
|
|
299
472
|
});
|