kimaki 1.5.0 → 1.6.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 (60) hide show
  1. package/dist/commands/worktree-commands.d.ts +2 -2
  2. package/dist/coordinator-agents.e2e.test.js +24 -2
  3. package/dist/coordinator-agents.e2e.test.js.map +1 -1
  4. package/dist/db.d.ts +3 -3
  5. package/dist/ingress.js +23 -3
  6. package/dist/ingress.js.map +1 -1
  7. package/dist/lock-routes.d.ts +33 -33
  8. package/dist/project.d.ts +1 -1
  9. package/dist/prompt.d.ts +1 -1
  10. package/dist/scheduler.d.ts +3 -3
  11. package/dist/schema.d.ts +6 -6
  12. package/dist/session-events.d.ts +1 -1
  13. package/dist/session-ops.e2e.test.js +26 -0
  14. package/dist/session-ops.e2e.test.js.map +1 -1
  15. package/dist/sessions.d.ts +9 -9
  16. package/dist/slash-commands.js +6 -2
  17. package/dist/slash-commands.js.map +1 -1
  18. package/dist/sleeps.d.ts +1 -1
  19. package/dist/voice.d.ts +1 -1
  20. package/dist/voice.js +10 -2
  21. package/dist/voice.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/coordinator-agents.e2e.test.ts +25 -2
  24. package/src/ingress.ts +19 -2
  25. package/src/session-ops.e2e.test.ts +27 -0
  26. package/src/slash-commands.ts +6 -2
  27. package/src/voice.ts +10 -2
  28. package/dist/mainmast-idle.e2e.test.d.ts +0 -1
  29. package/dist/mainmast-idle.e2e.test.js +0 -99
  30. package/dist/mainmast-idle.e2e.test.js.map +0 -1
  31. package/dist/mainmast-queue.e2e.test.d.ts +0 -1
  32. package/dist/mainmast-queue.e2e.test.js +0 -244
  33. package/dist/mainmast-queue.e2e.test.js.map +0 -1
  34. package/dist/mainmast-warmup.e2e.test.d.ts +0 -1
  35. package/dist/mainmast-warmup.e2e.test.js +0 -130
  36. package/dist/mainmast-warmup.e2e.test.js.map +0 -1
  37. package/dist/mainmast.d.ts +0 -6
  38. package/dist/mainmast.e2e.test.d.ts +0 -1
  39. package/dist/mainmast.e2e.test.js +0 -428
  40. package/dist/mainmast.e2e.test.js.map +0 -1
  41. package/dist/mainmast.js +0 -156
  42. package/dist/mainmast.js.map +0 -1
  43. package/dist/message-tool.e2e.test.d.ts +0 -1
  44. package/dist/message-tool.e2e.test.js +0 -39
  45. package/dist/message-tool.e2e.test.js.map +0 -1
  46. package/dist/plugin/mainmast.d.ts +0 -4
  47. package/dist/plugin/mainmast.js +0 -379
  48. package/dist/plugin/mainmast.js.map +0 -1
  49. package/dist/plugin/read-media.d.ts +0 -23
  50. package/dist/plugin/read-media.js +0 -72
  51. package/dist/plugin/read-media.js.map +0 -1
  52. package/dist/plugin/session-id/index.d.ts +0 -3
  53. package/dist/plugin/session-id/index.js +0 -18
  54. package/dist/plugin/session-id/index.js.map +0 -1
  55. package/dist/probe-live.tmp.d.ts +0 -1
  56. package/dist/probe-live.tmp.js +0 -82
  57. package/dist/probe-live.tmp.js.map +0 -1
  58. package/dist/tasks.e2e.test.d.ts +0 -1
  59. package/dist/tasks.e2e.test.js +0 -173
  60. package/dist/tasks.e2e.test.js.map +0 -1
@@ -1,130 +0,0 @@
1
- // The mainmast warmup (plugin/mainmast.ts) with short windows: keep-alives while
2
- // a worker thread runs, compaction when the channel is idle again, and the cap
3
- // that stops the warmup while a worker is stuck.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { afterAll, beforeAll, expect, test } from 'vitest';
7
- import { TEST_MODEL, TEST_USER_ID, seedProjectChannel, startOpencodeTestServer, startTestBot, startTwin, tempDataDir, textParts, toolParts, waitFor, waitForFooter, warmUp, } from "./test/harness.js";
8
- let server;
9
- let twin;
10
- let bot;
11
- const dataDir = tempDataDir();
12
- // Big enough to be worth compacting (plugin/mainmast.ts MIN_COMPACT_TOKENS).
13
- const bigUsage = { inputTokens: 120_000, outputTokens: 1, totalTokens: 120_001 };
14
- const WARM_INTERVAL_MS = 400;
15
- const WARM_MAX_MS = 1_500;
16
- function bigText(text) {
17
- return [
18
- { type: 'text-start', id: 't' },
19
- { type: 'text-delta', id: 't', delta: text },
20
- { type: 'text-end', id: 't' },
21
- { type: 'finish', finishReason: 'stop', usage: bigUsage },
22
- ];
23
- }
24
- // The mainmast starts a worker thread, then answers IDLE.
25
- function delegate({ marker, worker, channelId }) {
26
- const command = `kimaki send --channel ${channelId} --name 'Worker ${worker}' --prompt '${worker} run' --parent-session "$OPENCODE_SESSION_ID"`;
27
- return [
28
- {
29
- id: `${marker}-send`,
30
- priority: 100,
31
- when: { latestUserTextIncludes: marker },
32
- then: { parts: toolParts({ toolCallId: `${marker}-call`, toolName: 'shell', input: { command, description: 'Start the worker', hasSideEffect: true } }) },
33
- },
34
- { id: `${marker}-idle`, priority: 101, when: { latestUserTextIncludes: marker, rawPromptIncludes: `${marker}-call` }, then: { parts: [...bigText('IDLE')] } },
35
- ];
36
- }
37
- beforeAll(async () => {
38
- twin = await startTwin();
39
- server = await startOpencodeTestServer({
40
- plugins: [path.resolve('src/test/generate-log-plugin')],
41
- models: { [TEST_MODEL]: { name: TEST_MODEL, limit: { context: 1_000_000, output: 32_000 } } },
42
- matchers: [
43
- // OpenCode rejects a summary without the template headings.
44
- { id: 'compaction-summary', priority: 900, when: { latestUserTextIncludes: 'You MUST use this format for your response' }, then: { parts: textParts('## Objective\n- Keep coordinating') } },
45
- ...delegate({ marker: 'keepwarm-marker', worker: 'quickjob-marker', channelId: twin.channelId }),
46
- ...delegate({ marker: 'capped-marker', worker: 'stuckjob-marker', channelId: twin.channelId }),
47
- { id: 'quickjob', priority: 100, when: { latestUserTextIncludes: 'quickjob-marker' }, then: { parts: textParts('Quick job done.'), partDelaysMs: [0, 1_200, 0, 0] } },
48
- { id: 'stuckjob', priority: 100, when: { latestUserTextIncludes: 'stuckjob-marker' }, then: { parts: textParts('Stuck job done.'), partDelaysMs: [0, 3_000, 0, 0] } },
49
- { id: 'woken', priority: 300, when: { latestUserTextIncludes: '<session-finished' }, then: { parts: [...bigText('The worker finished.')] } },
50
- ],
51
- });
52
- await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory });
53
- await warmUp({ server });
54
- bot = await startTestBot({ dataDir, twin, server, mainmast: true, mainmastTimers: { warmIntervalMs: WARM_INTERVAL_MS, warmMaxMs: WARM_MAX_MS } });
55
- }, 60_000);
56
- afterAll(async () => {
57
- await bot?.stop();
58
- await twin?.stop();
59
- await server?.stop();
60
- fs.rmSync(dataDir, { recursive: true, force: true });
61
- });
62
- // Keep-alive requests of a session, from the test plugin.
63
- function keepAlives(sessionId) {
64
- const file = path.join(server.root, 'generate.log');
65
- if (!fs.existsSync(file))
66
- return 0;
67
- return fs.readFileSync(file, 'utf8').split('\n').filter((line) => line === sessionId).length;
68
- }
69
- async function compactions(sessionId) {
70
- const client = await server.client();
71
- const messages = await client.message.list({ sessionID: sessionId, order: 'desc', limit: 50 });
72
- return messages.data.filter((message) => message.type === 'compaction').length;
73
- }
74
- test('the mainmast stays warm while its worker runs, then compacts', async () => {
75
- const { discord, channelId } = twin;
76
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Start keepwarm-marker' });
77
- const root = await waitFor({ label: 'mainmast session', check: async () => bot.store.getState().roots[channelId] });
78
- await waitForFooter({ discord, threadId: channelId });
79
- await waitFor({ label: 'compaction after the wake', check: async () => (await compactions(root)) === 1 || null });
80
- expect(await discord.channel(channelId).text()).toMatchInlineSnapshot(`
81
- "--- from: user (tommy)
82
- Start keepwarm-marker
83
- --- from: assistant (TestBot)
84
- -# *using deterministic-provider/deterministic-v2 ⋅ build*
85
- -# ┣ shell _Start the worker_
86
- The worker finished.
87
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
88
- `);
89
- // The worker runs 1.2s, a keep-alive is due every 0.4s.
90
- expect(keepAlives(root)).toBeGreaterThan(0);
91
- const client = await server.client();
92
- const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 30 });
93
- // No compaction before the wake: the channel was busy.
94
- expect(messages.data.map((message) => message.type)).toMatchInlineSnapshot(`
95
- [
96
- "synthetic",
97
- "synthetic",
98
- "user",
99
- "assistant",
100
- "assistant",
101
- "idle",
102
- "synthetic",
103
- "assistant",
104
- "idle",
105
- "compaction",
106
- "idle",
107
- ]
108
- `);
109
- });
110
- test('a stuck worker stops the warmup at the cap', async () => {
111
- const { discord, channelId } = twin;
112
- const root = bot.store.getState().roots[channelId];
113
- const before = keepAlives(root);
114
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Start capped-marker' });
115
- const worker = await waitFor({
116
- label: 'stuck worker thread',
117
- check: async () => (await discord.channel(channelId).getThreads()).find((thread) => thread.name?.includes('stuckjob')),
118
- });
119
- // The cap ends the warmup while the worker still runs: compaction, no footer in the worker yet.
120
- await waitFor({ label: 'compaction at the cap', check: async () => (await compactions(root)) === 2 || null });
121
- const workerText = await discord.thread(worker.id).text();
122
- const atCap = keepAlives(root);
123
- await waitForFooter({ discord, threadId: worker.id });
124
- expect(workerText).not.toContain('-# *project');
125
- // 1.5s cap, one keep-alive every 0.4s: at most 3, none after the cap.
126
- expect(atCap - before).toBeGreaterThan(0);
127
- expect(atCap - before).toBeLessThanOrEqual(3);
128
- expect(keepAlives(root)).toBe(atCap);
129
- });
130
- //# sourceMappingURL=mainmast-warmup.e2e.test.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"mainmast-warmup.e2e.test.js","sourceRoot":"","sources":["../src/mainmast-warmup.e2e.test.ts"],"names":[],"mappings":"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"}
@@ -1,6 +0,0 @@
1
- import { type Bot } from './bot.ts';
2
- import type { RunEnded } from './event-loop.ts';
3
- export declare function createMainmastNotes(): {
4
- register(bot: Bot): void;
5
- runEnded(run: RunEnded): void;
6
- };
@@ -1 +0,0 @@
1
- export {};
@@ -1,428 +0,0 @@
1
- // Mainmast channels: the channel is the conversation of one long session, it
2
- // starts worker threads with `kimaki send --parent-session`, and a finished
3
- // worker wakes it with a <session-finished> note.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { afterAll, beforeAll, expect, test } from 'vitest';
7
- import { send } from "./prompt.js";
8
- import { readSessionMarkdown } from "./session-events.js";
9
- import { TEST_USER_ID, scriptedTurn, seedProjectChannel, startOpencodeTestServer, startTestBot, startTwin, tempDataDir, textParts, waitFor, waitForFooter, warmUp, } from "./test/harness.js";
10
- let server;
11
- let twin;
12
- let bot;
13
- const dataDir = tempDataDir();
14
- beforeAll(async () => {
15
- // The twin first: the delegating matcher needs its channel ID.
16
- twin = await startTwin();
17
- server = await startOpencodeTestServer({
18
- matchers: [
19
- // The role text reaches the model once per request, also when the plugin checks the request.
20
- { id: 'role-twice', priority: 500, when: { latestUserTextIncludes: 'once-marker', rawPromptRegex: '## mainmast: you coordinate[\\s\\S]*## mainmast: you coordinate' }, then: { parts: textParts('Role text twice.') } },
21
- { id: 'role-missing', priority: 400, when: { latestUserTextIncludes: 'once-marker', rawPromptRegex: '^(?![\\s\\S]*## mainmast: you coordinate)' }, then: { parts: textParts('Role text missing.') } },
22
- { id: 'hello', priority: 100, when: { latestUserTextIncludes: 'hello-marker' }, then: { parts: textParts('Hello from the mainmast.') } },
23
- // The mainmast delegates: one shell call to `kimaki send`, then IDLE (the new thread already shows).
24
- ...scriptedTurn({
25
- marker: 'delegate-marker',
26
- steps: [{
27
- id: 'call-send-worker',
28
- tool: 'shell',
29
- input: {
30
- command: `kimaki send --channel ${twin.channelId} --name 'Worker task' --prompt 'worker-marker do the job' --parent-session "$OPENCODE_SESSION_ID"`,
31
- description: 'Start the worker thread',
32
- hasSideEffect: true,
33
- },
34
- }],
35
- finalText: 'IDLE',
36
- }),
37
- // The default: the mainmast waits for the worker in the same shell call.
38
- ...scriptedTurn({
39
- marker: 'waitjob-marker',
40
- steps: [{
41
- id: 'call-send-wait',
42
- tool: 'shell',
43
- input: {
44
- command: `kimaki send --channel ${twin.channelId} --name 'Waited task' --prompt 'waited-worker-marker do the job' --parent-session "$OPENCODE_SESSION_ID" --wait`,
45
- description: 'Run the worker and wait',
46
- hasSideEffect: true,
47
- },
48
- }],
49
- finalText: 'The waited worker is done.',
50
- }),
51
- { id: 'waited-worker', priority: 100, when: { latestUserTextIncludes: 'waited-worker-marker' }, then: { parts: textParts('Waited job done.') } },
52
- // Slow enough that the delegating run ends first: the note then wakes an idle mainmast.
53
- { id: 'worker', priority: 100, when: { latestUserTextIncludes: 'worker-marker' }, then: { parts: textParts('Job done in the worker.'), defaultPartDelayMs: 300 } },
54
- // A worker that uploads a screenshot; session read lists its URL, the note does not.
55
- ...scriptedTurn({
56
- marker: 'upload-marker',
57
- steps: [{ id: 'call-upload', tool: 'shell', input: { command: 'kimaki upload-to-discord shot.png', description: 'Upload the screenshot', hasSideEffect: true } }],
58
- finalText: 'Took the screenshot.',
59
- }),
60
- { id: 'woken-with-media', priority: 320, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Took the screenshot' }, then: { parts: textParts('Here is the screenshot.') } },
61
- { id: 'user-steer', priority: 100, when: { latestUserTextIncludes: 'user-steer-marker' }, then: { parts: textParts('Steered job done.') } },
62
- { id: 'follow-up', priority: 100, when: { latestUserTextIncludes: 'follow-up-marker' }, then: { parts: textParts('Follow-up done.') } },
63
- { id: 'woken-by-follow-up', priority: 340, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Follow-up done' }, then: { parts: textParts('The follow-up finished.') } },
64
- { id: 'fork', priority: 100, when: { latestUserTextIncludes: 'fork-marker' }, then: { parts: textParts('Fork job done.') } },
65
- { id: 'slow-job', priority: 100, when: { latestUserTextIncludes: 'slow-job-marker' }, then: { parts: textParts('Slow job done.'), defaultPartDelayMs: 500 } },
66
- { id: 'woken-by-slow-job', priority: 330, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Slow job done' }, then: { parts: textParts('The slow job finished.') } },
67
- { id: 'woken-by-fork', priority: 310, when: { latestUserTextIncludes: '<session-finished', rawPromptIncludes: 'Fork job done.' }, then: { parts: textParts('The fork finished the job.') } },
68
- { id: 'btw', priority: 100, when: { latestUserTextIncludes: 'btw-marker' }, then: { parts: textParts('Side answer.') } },
69
- { id: 'side', priority: 100, when: { latestUserTextIncludes: 'side-marker' }, then: { parts: textParts('Side check done.') } },
70
- // The silent note of the side thread is in the history only when it arrived.
71
- { id: 'recap-seen', priority: 200, when: { latestUserTextIncludes: 'recap-marker', rawPromptIncludes: 'Side check done.' }, then: { parts: textParts('A side thread finished meanwhile.') } },
72
- { id: 'recap-unseen', priority: 150, when: { latestUserTextIncludes: 'recap-marker' }, then: { parts: textParts('Nothing new.') } },
73
- { id: 'woken', priority: 300, when: { latestUserTextIncludes: '<session-finished' }, then: { parts: textParts('The worker finished the job.') } },
74
- // A wake with a stale role text in the history: the plugin adds the current one to this request.
75
- { id: 'stale-wake-current', priority: 400, when: { latestUserTextRegex: 'stale-wake-marker[\\s\\S]*## mainmast: you coordinate this channel' }, then: { parts: textParts('Current role text present.') } },
76
- { id: 'stale-wake-old', priority: 350, when: { latestUserTextIncludes: 'stale-wake-marker' }, then: { parts: textParts('Current role text missing.') } },
77
- ],
78
- });
79
- await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory });
80
- await warmUp({ server });
81
- bot = await startTestBot({ dataDir, twin, server, mainmast: true });
82
- }, 60_000);
83
- // Role texts the kimaki plugin stored in a session (plugin/mainmast.ts), oldest first.
84
- async function roleTexts(sessionID) {
85
- const client = await server.client();
86
- const messages = await client.message.list({ sessionID, order: 'desc', limit: 100 });
87
- return [...messages.data].reverse().flatMap((message) => (message.type === 'synthetic' && message.text.includes('<kimaki-mainmast>') ? [message.text] : []));
88
- }
89
- afterAll(async () => {
90
- await bot?.stop();
91
- await twin?.stop();
92
- await server?.stop();
93
- fs.rmSync(dataDir, { recursive: true, force: true });
94
- });
95
- test('channel messages go to one session that replies in the channel', async () => {
96
- const { discord, channelId } = twin;
97
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Say hi hello-marker' });
98
- await waitForFooter({ discord, threadId: channelId });
99
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Again hello-marker once-marker' });
100
- await waitForFooter({ discord, threadId: channelId, count: 2 });
101
- expect(await discord.channel(channelId).text({ showReplies: true })).toMatchInlineSnapshot(`
102
- "--- from: user (tommy)
103
- Say hi hello-marker
104
- --- from: assistant (TestBot)
105
- -# *using deterministic-provider/deterministic-v2 ⋅ build*
106
- [reply to tommy: Say hi hello-marker]
107
- Hello from the mainmast.
108
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
109
- --- from: user (tommy)
110
- Again hello-marker once-marker
111
- --- from: assistant (TestBot)
112
- [reply to tommy: Again hello-marker once-marker]
113
- Hello from the mainmast.
114
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
115
- `);
116
- expect(await discord.channel(channelId).getThreads()).toHaveLength(0);
117
- // The coordinator role comes from the plugin, once: not from the frozen instructions entry.
118
- const root = bot.store.getState().roots[channelId];
119
- const roles = await roleTexts(root);
120
- expect(roles).toHaveLength(1);
121
- expect(roles[0]).toContain('## mainmast: you coordinate this channel');
122
- expect(roles[0]).toContain(`--parent-session ${root}`);
123
- const entries = await (await server.client()).session.instructions.entry.list({ sessionID: root });
124
- expect(JSON.stringify(entries)).not.toContain('## mainmast');
125
- });
126
- test('a worker started with --parent-session gets a thread and wakes the mainmast when done', async () => {
127
- const { discord, channelId } = twin;
128
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project'));
129
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Fix it delegate-marker' });
130
- const thread = await discord.channel(channelId).waitForThread({ timeout: 8_000 });
131
- await waitForFooter({ discord, threadId: thread.id });
132
- await waitFor({
133
- label: 'woken reply',
134
- check: async () => (await discord.channel(channelId).text()).includes('The worker finished the job.') || null,
135
- });
136
- // The dispatch turn answered IDLE after its tool line: only the woken report gets a footer.
137
- await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 });
138
- // Only the first answer text of a user prompt replies to it: never tool lines, the wake answer or a worker thread.
139
- expect((await discord.channel(channelId).text({ showReplies: true })).replaceAll(thread.id, 'worker-thread')).toMatchInlineSnapshot(`
140
- "--- from: user (tommy)
141
- Say hi hello-marker
142
- --- from: assistant (TestBot)
143
- -# *using deterministic-provider/deterministic-v2 ⋅ build*
144
- [reply to tommy: Say hi hello-marker]
145
- Hello from the mainmast.
146
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
147
- --- from: user (tommy)
148
- Again hello-marker once-marker
149
- --- from: assistant (TestBot)
150
- [reply to tommy: Again hello-marker once-marker]
151
- Hello from the mainmast.
152
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
153
- --- from: user (tommy)
154
- Fix it delegate-marker
155
- --- from: assistant (TestBot)
156
- -# ┣ shell _Start the worker thread_
157
- The worker finished the job.
158
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
159
- `);
160
- expect(await discord.thread(thread.id).text({ showReplies: true })).toMatchInlineSnapshot(`
161
- "--- from: assistant (TestBot)
162
- -# » **CLI:** worker-marker do the job
163
- -# *using deterministic-provider/deterministic-v2 ⋅ build*
164
- Job done in the worker.
165
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
166
- `);
167
- expect(thread.name).toBe('Worker task');
168
- const workerRoles = await roleTexts(bot.store.getState().roots[thread.id]);
169
- expect(workerRoles).toHaveLength(1);
170
- expect(workerRoles[0]).toContain(`## worker of a mainmast`);
171
- expect(workerRoles[0]).toContain(`kimaki session read ${channelId}`);
172
- });
173
- test('a btw fork of a worker sent with --parent-session wakes the mainmast when done', async () => {
174
- const { discord, channelId } = twin;
175
- const root = bot.store.getState().roots[channelId];
176
- const worker = (await discord.channel(channelId).getThreads()).find((candidate) => candidate.name === 'Worker task');
177
- if (!worker)
178
- throw new Error('no Worker task thread');
179
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project')).length;
180
- // What the coordinator runs: kimaki send --thread <worker> --prompt '... btw' --parent-session <mainmast>
181
- const forked = await send(bot, { threadId: worker.id, prompt: 'fork-marker related job. btw', parentSessionId: root, senderSessionId: root });
182
- if (forked instanceof Error)
183
- throw forked;
184
- await waitForFooter({ discord, threadId: forked.threadId });
185
- await waitForFooter({ discord, threadId: channelId, count: footers + 1 });
186
- const text = await discord.channel(channelId).text();
187
- expect(text.slice(text.lastIndexOf('The worker finished the job.'))).toMatchInlineSnapshot(`
188
- "The worker finished the job.
189
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
190
- The fork finished the job.
191
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
192
- `);
193
- const fork = await (await server.client()).session.get({ sessionID: forked.sessionId });
194
- expect(Reflect.get(Object(fork.metadata?.['kimaki']), 'parentSessionId')).toBe(root);
195
- expect((await roleTexts(fork.id)).at(-1)).toContain('## worker of a mainmast');
196
- });
197
- test('session read lists the Discord URL of a file a worker uploaded; its note does not', async () => {
198
- const { discord, channelId } = twin;
199
- const root = bot.store.getState().roots[channelId];
200
- fs.writeFileSync(path.join(server.projectDirectory, 'shot.png'), 'png');
201
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project')).length;
202
- const started = await send(bot, { channelId, name: 'Screenshot task', prompt: 'upload-marker take a screenshot', parentSessionId: root, senderSessionId: root });
203
- if (started instanceof Error)
204
- throw started;
205
- await waitForFooter({ discord, threadId: started.threadId });
206
- await waitForFooter({ discord, threadId: channelId, count: footers + 1 });
207
- const text = await discord.channel(channelId).text();
208
- expect(text.slice(text.lastIndexOf('-# *project', text.lastIndexOf('-# *project') - 1))).toMatchInlineSnapshot(`
209
- "-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
210
- Here is the screenshot.
211
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
212
- `);
213
- const client = await server.client();
214
- const messages = await client.message.list({ sessionID: root, order: 'desc', limit: 10 });
215
- const note = messages.data.find((message) => message.type === 'synthetic' && message.text.includes('Screenshot task'));
216
- expect(note?.type === 'synthetic' && note.text.replaceAll(started.sessionId, 'ses_worker').replaceAll(started.threadId, 'worker-thread')).toMatchInlineSnapshot(`
217
- "<session-finished sessionID="ses_worker" threadID="worker-thread" state="completed" title="Screenshot task" wake="true">
218
- Took the screenshot.
219
- More: kimaki session read ses_worker
220
- The user sees nothing else about this. When it is work they asked for, a failure or a decision they must make, tell them the outcome in a few plain sentences, with the thread link <#worker-thread> once. Otherwise reply exactly IDLE.
221
- </session-finished>"
222
- `);
223
- const read = await readSessionMarkdown({ client, sessionId: started.sessionId });
224
- expect(read instanceof Error ? read : read.slice(read.indexOf('Took the screenshot.'), read.indexOf('Only prompts'))).toMatchInlineSnapshot(`
225
- "Took the screenshot.
226
-
227
- Media uploaded to Discord:
228
- - data:application/octet-stream;base64,cG5n
229
-
230
- "
231
- `);
232
- // --full and transcripts hide tool outputs but keep the uploaded media on the tool line.
233
- const full = await readSessionMarkdown({ client, sessionId: started.sessionId, full: true, thinking: true });
234
- expect(full instanceof Error ? full : full.slice(full.indexOf('tool shell'), full.indexOf('finish: tool-calls')).trim()).toMatchInlineSnapshot(`
235
- "tool shell [completed] {"command":"kimaki upload-to-discord shot.png","description":"Upload the screenshot","hasSideEffect":true}
236
- Media uploaded to Discord:
237
- - data:application/octet-stream;base64,cG5n"
238
- `);
239
- });
240
- test('a run the user prompts in a worker thread is a silent note; a follow-up the mainmast sends wakes it', async () => {
241
- const { discord, channelId } = twin;
242
- const root = bot.store.getState().roots[channelId];
243
- const client = await server.client();
244
- const worker = (await discord.channel(channelId).getThreads()).find((candidate) => candidate.name === 'Worker task');
245
- if (!worker)
246
- throw new Error('no Worker task thread');
247
- const footers = () => discord.channel(channelId).getMessages().then((messages) => messages.filter((message) => message.content.startsWith('-# *project')).length);
248
- const before = await footers();
249
- const workerFooters = (await discord.thread(worker.id).getMessages()).filter((message) => message.content.startsWith('-# *project')).length;
250
- await discord.thread(worker.id).user(TEST_USER_ID).sendMessage({ content: 'Also check the logs user-steer-marker' });
251
- await waitForFooter({ discord, threadId: worker.id, count: workerFooters + 1 });
252
- const note = await waitFor({
253
- label: 'silent note of the steered run',
254
- check: async () => {
255
- const inbox = await client.session.inbox.list({ sessionID: root });
256
- const item = inbox.find((candidate) => candidate.type === 'synthetic' && candidate.payload.text.includes('Steered job done.'));
257
- return item?.type === 'synthetic' ? item.payload.text : null;
258
- },
259
- });
260
- expect(note.replaceAll(worker.id, 'worker-thread').replace(/ses_\w+/g, 'ses_worker')).toMatchInlineSnapshot(`
261
- "<session-finished sessionID="ses_worker" threadID="worker-thread" state="completed" title="Worker task" wake="false">
262
- <user-prompt username="tommy">
263
- Also check the logs user-steer-marker
264
- </user-prompt>
265
- Steered job done.
266
- More: kimaki session read ses_worker
267
- </session-finished>"
268
- `);
269
- // Not woken: no new footer in the channel.
270
- for (let i = 0; i < 10; i++) {
271
- await new Promise((resolve) => setTimeout(resolve, 20));
272
- expect(await footers()).toBe(before);
273
- }
274
- // What the coordinator runs: kimaki send --thread <worker> --prompt '...', from its own shell.
275
- const followed = await send(bot, { threadId: worker.id, prompt: 'Now the follow-up-marker', senderSessionId: root });
276
- if (followed instanceof Error)
277
- throw followed;
278
- await waitForFooter({ discord, threadId: channelId, count: before + 1 });
279
- const text = await discord.channel(channelId).text();
280
- expect(text.slice(text.lastIndexOf('-# *project', text.lastIndexOf('-# *project') - 1))).toMatchInlineSnapshot(`
281
- "-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
282
- The follow-up finished.
283
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
284
- `);
285
- });
286
- test('a worker started with --wait answers through the wait, with no note', async () => {
287
- const { discord, channelId } = twin;
288
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project'));
289
- const threads = (await discord.channel(channelId).getThreads()).length;
290
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Fix it waitjob-marker' });
291
- await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 });
292
- const thread = (await discord.channel(channelId).getThreads()).find((candidate) => candidate.name === 'Waited task');
293
- if (!thread)
294
- throw new Error('no Waited task thread');
295
- await waitForFooter({ discord, threadId: thread.id });
296
- const root = bot.store.getState().roots[channelId];
297
- const client = await server.client();
298
- // No note arrives: not in the inbox, no wake, no "finished" line.
299
- for (let i = 0; i < 10; i++) {
300
- await new Promise((resolve) => setTimeout(resolve, 20));
301
- const inbox = await client.session.inbox.list({ sessionID: root });
302
- expect(inbox.filter((item) => item.type === 'synthetic' && item.payload.text.includes('Waited task'))).toHaveLength(0);
303
- }
304
- const text = (await discord.channel(channelId).text()).replaceAll(thread.id, 'worker-thread');
305
- // The answer of the busy prompt, then the queued one starts: its IDLE answer posts nothing.
306
- expect(text.slice(text.lastIndexOf('[reply to tommy: Take a while'))).toMatchInlineSnapshot(`"*"`);
307
- expect(await discord.channel(channelId).getThreads()).toHaveLength(threads + 1);
308
- const messages = await client.message.list({ sessionID: root, order: 'desc', limit: 20 });
309
- expect(messages.data.some((message) => message.type === 'synthetic' && JSON.stringify(message).includes('Waited task'))).toBe(false);
310
- });
311
- test('another thread of the channel leaves a silent note for the next prompt', async () => {
312
- const { discord, channelId } = twin;
313
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project'));
314
- const started = await send(bot, { channelId, prompt: 'side-marker check something' });
315
- if (started instanceof Error)
316
- throw started;
317
- await waitForFooter({ discord, threadId: started.threadId });
318
- const client = await server.client();
319
- const root = bot.store.getState().roots[channelId];
320
- await waitFor({
321
- label: 'silent note in the mainmast',
322
- check: async () => {
323
- // Not delivered yet: a note without wake waits in the inbox for the next run.
324
- const inbox = await client.session.inbox.list({ sessionID: root });
325
- return inbox.some((item) => item.type === 'synthetic' && item.payload.text.includes('wake="false"')) || null;
326
- },
327
- });
328
- // Not woken: no new footer in the channel until the user writes.
329
- expect((await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project'))).toHaveLength(footers.length);
330
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'What happened? recap-marker' });
331
- await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 });
332
- const text = await discord.channel(channelId).text();
333
- expect(text.slice(text.lastIndexOf('--- from: user'))).toMatchInlineSnapshot(`
334
- "--- from: user (tommy)
335
- What happened? recap-marker
336
- --- from: assistant (TestBot)
337
- A side thread finished meanwhile.
338
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
339
- `);
340
- });
341
- test('a btw fork of the mainmast is a worker: no coordinator timers', async () => {
342
- const { discord, channelId } = twin;
343
- const before = (await discord.channel(channelId).getThreads()).length;
344
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Why that choice btw-marker. btw' });
345
- const thread = await waitFor({
346
- label: 'btw thread',
347
- check: async () => (await discord.channel(channelId).getThreads()).slice(before).find((candidate) => candidate.name?.startsWith('btw:')) ?? null,
348
- });
349
- await waitForFooter({ discord, threadId: thread.id });
350
- const client = await server.client();
351
- const fork = await client.session.get({ sessionID: bot.store.getState().roots[thread.id] });
352
- expect(Reflect.get(Object(fork.metadata?.['kimaki']), 'mainmast')).toBeUndefined();
353
- // The fork copied the coordinator role; its own worker role comes after it.
354
- const roles = await roleTexts(fork.id);
355
- expect(roles.map((text) => (text.includes('## worker of a mainmast') ? 'worker' : 'coordinator'))).toEqual(['coordinator', 'worker']);
356
- });
357
- test('a stale coordinator role text is replaced: on a wake for that request, then stored at the next prompt', async () => {
358
- const { discord, channelId } = twin;
359
- const root = bot.store.getState().roots[channelId];
360
- const client = await server.client();
361
- const current = (await roleTexts(root)).at(-1);
362
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project')).length;
363
- // Like a role text of older plugin code, as the latest one in the history, waking the session.
364
- await client.session.synthetic({ sessionID: root, text: '<system-reminder>\n<kimaki-mainmast>\nold role text stale-wake-marker\n</kimaki-mainmast>\n</system-reminder>' });
365
- await waitForFooter({ discord, threadId: channelId, count: footers + 1 });
366
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Again hello-marker' });
367
- await waitForFooter({ discord, threadId: channelId, count: footers + 2 });
368
- const text = await discord.channel(channelId).text();
369
- expect(text.slice(text.lastIndexOf('Session forked!')).replace(/<#\d+>/, '<#btw-thread>')).toMatchInlineSnapshot(`
370
- "Session forked! Continue in <#btw-thread>
371
- Current role text present.
372
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
373
- --- from: user (tommy)
374
- Again hello-marker
375
- --- from: assistant (TestBot)
376
- Hello from the mainmast.
377
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
378
- `);
379
- const roles = await roleTexts(root);
380
- expect(roles.at(-1)).toBe(current);
381
- expect(roles.at(-2)).toContain('stale-wake-marker');
382
- });
383
- test('the mainmast shows typing while a worker runs in the background', async () => {
384
- const { discord, channelId } = twin;
385
- const root = bot.store.getState().roots[channelId];
386
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project')).length;
387
- discord.channel(channelId).clearTypingEvents();
388
- const started = await send(bot, { channelId, name: 'Slow task', prompt: 'slow-job-marker take your time', parentSessionId: root, senderSessionId: root });
389
- if (started instanceof Error)
390
- throw started;
391
- await discord.channel(channelId).waitForTypingEvent();
392
- // Read when typing starts: it must come from the running worker, not from the wake after it.
393
- const workerAtTyping = await discord.thread(started.threadId).text();
394
- await waitForFooter({ discord, threadId: started.threadId });
395
- await waitForFooter({ discord, threadId: channelId, count: footers + 1 });
396
- const text = await discord.channel(channelId).text();
397
- expect(text.slice(text.lastIndexOf('-# *project', text.lastIndexOf('-# *project') - 1))).toMatchInlineSnapshot(`
398
- "-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
399
- The slow job finished.
400
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
401
- `);
402
- expect(workerAtTyping).not.toContain('-# *project');
403
- });
404
- test('the mainmast survives a bot restart; /new-mainmast starts a fresh one', async () => {
405
- const { discord, channelId } = twin;
406
- const root = bot.store.getState().roots[channelId];
407
- await bot.stop();
408
- bot = await startTestBot({ dataDir, twin, server, mainmast: true });
409
- expect(bot.store.getState().roots[channelId]).toBe(root);
410
- const { id: interactionId } = await discord.channel(channelId).user(TEST_USER_ID).runSlashCommand({ name: 'new-mainmast' });
411
- await discord.channel(channelId).waitForInteractionAck({ interactionId, timeout: 4_000 });
412
- expect(bot.store.getState().roots[channelId]).toBeUndefined();
413
- const footers = (await discord.channel(channelId).getMessages()).filter((message) => message.content.startsWith('-# *project')).length;
414
- await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Fresh start hello-marker' });
415
- await waitForFooter({ discord, threadId: channelId, count: footers + 1 });
416
- const fresh = bot.store.getState().roots[channelId];
417
- expect(fresh && fresh !== root).toBe(true);
418
- const text = await discord.channel(channelId).text();
419
- expect(text.slice(text.lastIndexOf('--- from: user'))).toMatchInlineSnapshot(`
420
- "--- from: user (tommy)
421
- Fresh start hello-marker
422
- --- from: assistant (TestBot)
423
- -# *using deterministic-provider/deterministic-v2 ⋅ build*
424
- Hello from the mainmast.
425
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
426
- `);
427
- });
428
- //# sourceMappingURL=mainmast.e2e.test.js.map