kimaki 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/fonts/Geist-Bold.ttf +0 -0
- package/assets/fonts/Geist-Regular.ttf +0 -0
- package/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/assets/fonts/OFL.txt +92 -0
- package/dist/agent-ui.e2e.test.js +27 -13
- package/dist/agent-ui.e2e.test.js.map +1 -1
- package/dist/agent-ui.js +2 -1
- package/dist/agent-ui.js.map +1 -1
- package/dist/analytics.e2e.test.js +1 -1
- package/dist/analytics.e2e.test.js.map +1 -1
- package/dist/autostart.d.ts +4 -0
- package/dist/autostart.js +55 -10
- package/dist/autostart.js.map +1 -1
- package/dist/bot.d.ts +10 -1
- package/dist/bot.js +14 -6
- package/dist/bot.js.map +1 -1
- package/dist/btw.e2e.test.js +3 -3
- package/dist/builtin-skills.d.ts +1 -1
- package/dist/builtin-skills.js +1 -1
- package/dist/builtin-skills.js.map +1 -1
- package/dist/builtin-skills.test.js +3 -4
- package/dist/builtin-skills.test.js.map +1 -1
- package/dist/catalog-refresh.e2e.test.js +2 -2
- package/dist/cli/bot.js +25 -14
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli/discord.js +32 -6
- package/dist/cli/discord.js.map +1 -1
- package/dist/cli/schedule.js +23 -9
- package/dist/cli/schedule.js.map +1 -1
- package/dist/cli/session.js +37 -12
- package/dist/cli/session.js.map +1 -1
- package/dist/cli/shared.d.ts +5 -3
- package/dist/cli/shared.js +45 -10
- package/dist/cli/shared.js.map +1 -1
- package/dist/cli-discord.e2e.test.js +13 -8
- package/dist/cli-discord.e2e.test.js.map +1 -1
- package/dist/cli-lock.e2e.test.js +87 -20
- package/dist/cli-lock.e2e.test.js.map +1 -1
- package/dist/cli-read.e2e.test.js +18 -9
- package/dist/cli-read.e2e.test.js.map +1 -1
- package/dist/cli-sessions.e2e.test.js +4 -3
- package/dist/cli-sessions.e2e.test.js.map +1 -1
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/preference-commands.d.ts +4 -0
- package/dist/commands/preference-commands.js +38 -4
- package/dist/commands/preference-commands.js.map +1 -1
- package/dist/commands/session-commands.js +11 -8
- package/dist/commands/session-commands.js.map +1 -1
- package/dist/commands/thread-commands.js +94 -24
- package/dist/commands/thread-commands.js.map +1 -1
- package/dist/commands/worktree-commands.d.ts +5 -6
- package/dist/commands/worktree-commands.js +8 -7
- package/dist/commands/worktree-commands.js.map +1 -1
- package/dist/commands.e2e.test.js +25 -21
- package/dist/commands.e2e.test.js.map +1 -1
- package/dist/coordinator-agents.e2e.test.d.ts +1 -0
- package/dist/coordinator-agents.e2e.test.js +113 -0
- package/dist/coordinator-agents.e2e.test.js.map +1 -0
- package/dist/coordinator-idle.e2e.test.d.ts +1 -0
- package/dist/coordinator-idle.e2e.test.js +100 -0
- package/dist/coordinator-idle.e2e.test.js.map +1 -0
- package/dist/coordinator-queue.e2e.test.d.ts +1 -0
- package/dist/coordinator-queue.e2e.test.js +244 -0
- package/dist/coordinator-queue.e2e.test.js.map +1 -0
- package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
- package/dist/coordinator-warmup.e2e.test.js +131 -0
- package/dist/coordinator-warmup.e2e.test.js.map +1 -0
- package/dist/coordinator.d.ts +6 -0
- package/dist/coordinator.e2e.test.d.ts +1 -0
- package/dist/coordinator.e2e.test.js +505 -0
- package/dist/coordinator.e2e.test.js.map +1 -0
- package/dist/coordinator.js +164 -0
- package/dist/coordinator.js.map +1 -0
- package/dist/db.d.ts +78 -3
- package/dist/db.test.js +1 -0
- package/dist/db.test.js.map +1 -1
- package/dist/diagram.d.ts +11 -0
- package/dist/diagram.js +232 -0
- package/dist/diagram.js.map +1 -0
- package/dist/diagram.test.d.ts +1 -0
- package/dist/diagram.test.js +52 -0
- package/dist/diagram.test.js.map +1 -0
- package/dist/duration.js +13 -5
- package/dist/duration.js.map +1 -1
- package/dist/effects.d.ts +6 -1
- package/dist/effects.js +97 -26
- package/dist/effects.js.map +1 -1
- package/dist/event-loop.d.ts +1 -1
- package/dist/event-loop.js +73 -43
- package/dist/event-loop.js.map +1 -1
- package/dist/format-parts.d.ts +21 -4
- package/dist/format-parts.js +40 -23
- package/dist/format-parts.js.map +1 -1
- package/dist/gateway-replay.e2e.test.js +4 -4
- package/dist/harness.e2e.test.js +2 -2
- package/dist/harness.e2e.test.js.map +1 -1
- package/dist/hello-thread.e2e.test.js +56 -5
- package/dist/hello-thread.e2e.test.js.map +1 -1
- package/dist/ingress.js +47 -12
- package/dist/ingress.js.map +1 -1
- package/dist/interaction-context.d.ts +4 -4
- package/dist/interaction-context.js +9 -7
- package/dist/interaction-context.js.map +1 -1
- package/dist/interrupt.e2e.test.js +8 -5
- package/dist/interrupt.e2e.test.js.map +1 -1
- package/dist/legacy.e2e.test.js +1 -1
- package/dist/lock-routes.d.ts +83 -31
- package/dist/lock-routes.js +68 -8
- package/dist/lock-routes.js.map +1 -1
- package/dist/lock-server.d.ts +3 -2
- package/dist/lock-server.js +23 -7
- package/dist/lock-server.js.map +1 -1
- package/dist/main.d.ts +4 -2
- package/dist/main.js +13 -3
- package/dist/main.js.map +1 -1
- package/dist/mainmast-idle.e2e.test.d.ts +1 -0
- package/dist/mainmast-idle.e2e.test.js +99 -0
- package/dist/mainmast-idle.e2e.test.js.map +1 -0
- package/dist/mainmast-queue.e2e.test.d.ts +1 -0
- package/dist/mainmast-queue.e2e.test.js +244 -0
- package/dist/mainmast-queue.e2e.test.js.map +1 -0
- package/dist/mainmast-warmup.e2e.test.d.ts +1 -0
- package/dist/mainmast-warmup.e2e.test.js +130 -0
- package/dist/mainmast-warmup.e2e.test.js.map +1 -0
- package/dist/mainmast.d.ts +6 -0
- package/dist/mainmast.e2e.test.d.ts +1 -0
- package/dist/mainmast.e2e.test.js +428 -0
- package/dist/mainmast.e2e.test.js.map +1 -0
- package/dist/mainmast.js +156 -0
- package/dist/mainmast.js.map +1 -0
- package/dist/markdown/markdown.test.js +4 -4
- package/dist/markdown/render-markdown.js +6 -5
- package/dist/markdown/render-markdown.js.map +1 -1
- package/dist/onboarding-cli.e2e.test.js +7 -6
- package/dist/onboarding-cli.e2e.test.js.map +1 -1
- package/dist/onboarding.d.ts +3 -10
- package/dist/onboarding.e2e.test.js +8 -10
- package/dist/onboarding.e2e.test.js.map +1 -1
- package/dist/onboarding.js +14 -24
- package/dist/onboarding.js.map +1 -1
- package/dist/permissions.e2e.test.js +3 -3
- package/dist/plugin/coordinator.d.ts +4 -0
- package/dist/plugin/coordinator.js +389 -0
- package/dist/plugin/coordinator.js.map +1 -0
- package/dist/plugin/index.js +76 -17
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/mainmast.d.ts +4 -0
- package/dist/plugin/mainmast.js +379 -0
- package/dist/plugin/mainmast.js.map +1 -0
- package/dist/plugin.e2e.test.js +1 -1
- package/dist/preference-commands.e2e.test.js +103 -24
- package/dist/preference-commands.e2e.test.js.map +1 -1
- package/dist/project.d.ts +1 -1
- package/dist/prompt.d.ts +22 -9
- package/dist/prompt.js +64 -48
- package/dist/prompt.js.map +1 -1
- package/dist/questions.e2e.test.js +13 -13
- package/dist/queue-sources.e2e.test.d.ts +1 -0
- package/dist/queue-sources.e2e.test.js +168 -0
- package/dist/queue-sources.e2e.test.js.map +1 -0
- package/dist/queue.d.ts +9 -1
- package/dist/queue.e2e.test.js +48 -30
- package/dist/queue.e2e.test.js.map +1 -1
- package/dist/queue.js +97 -24
- package/dist/queue.js.map +1 -1
- package/dist/queue.test.d.ts +1 -0
- package/dist/queue.test.js +57 -0
- package/dist/queue.test.js.map +1 -0
- package/dist/remote-send.e2e.test.js +6 -6
- package/dist/remote-send.js +2 -1
- package/dist/remote-send.js.map +1 -1
- package/dist/renderer.e2e.test.js +7 -7
- package/dist/restart.e2e.test.js +2 -2
- package/dist/routines.e2e.test.js +2 -2
- package/dist/scheduler.d.ts +3 -3
- package/dist/scheduler.e2e.test.js +43 -20
- package/dist/scheduler.e2e.test.js.map +1 -1
- package/dist/scheduler.js +4 -2
- package/dist/scheduler.js.map +1 -1
- package/dist/schema-sql.d.ts +1 -1
- package/dist/schema-sql.js +1 -1
- package/dist/schema-sql.js.map +1 -1
- package/dist/schema.d.ts +152 -6
- package/dist/schema.js +12 -0
- package/dist/schema.js.map +1 -1
- package/dist/session-commands.e2e.test.js +36 -11
- package/dist/session-commands.e2e.test.js.map +1 -1
- package/dist/session-events.d.ts +18 -6
- package/dist/session-events.js +166 -22
- package/dist/session-events.js.map +1 -1
- package/dist/session-ops.e2e.test.js +63 -12
- package/dist/session-ops.e2e.test.js.map +1 -1
- package/dist/sessions.d.ts +44 -21
- package/dist/sessions.js +174 -63
- package/dist/sessions.js.map +1 -1
- package/dist/shell.e2e.test.js +4 -4
- package/dist/slash-commands.js +6 -4
- package/dist/slash-commands.js.map +1 -1
- package/dist/slash-commands.test.js +2 -0
- package/dist/slash-commands.test.js.map +1 -1
- package/dist/sleep.e2e.test.js +29 -14
- package/dist/sleep.e2e.test.js.map +1 -1
- package/dist/sleeps.d.ts +2 -1
- package/dist/sleeps.js +21 -3
- package/dist/sleeps.js.map +1 -1
- package/dist/store.d.ts +1 -0
- package/dist/store.js +3 -2
- package/dist/store.js.map +1 -1
- package/dist/system-prompt.d.ts +3 -1
- package/dist/system-prompt.js +138 -64
- package/dist/system-prompt.js.map +1 -1
- package/dist/test/generate-log-plugin/index.d.ts +3 -0
- package/dist/test/generate-log-plugin/index.js +15 -0
- package/dist/test/generate-log-plugin/index.js.map +1 -0
- package/dist/test/harness.d.ts +20 -1
- package/dist/test/harness.js +51 -10
- package/dist/test/harness.js.map +1 -1
- package/dist/test/replay.js +11 -7
- package/dist/test/replay.js.map +1 -1
- package/dist/thread-reducer.d.ts +29 -3
- package/dist/thread-reducer.fixtures.test.js +58 -51
- package/dist/thread-reducer.fixtures.test.js.map +1 -1
- package/dist/thread-reducer.js +112 -25
- package/dist/thread-reducer.js.map +1 -1
- package/dist/thread-reducer.test.js +197 -24
- package/dist/thread-reducer.test.js.map +1 -1
- package/dist/voice-attachments.e2e.test.js +108 -18
- package/dist/voice-attachments.e2e.test.js.map +1 -1
- package/dist/voice-call.e2e.test.js +9 -9
- package/dist/voice-call.js +6 -6
- package/dist/voice-call.js.map +1 -1
- package/dist/voice-openai.e2e.test.js +59 -23
- package/dist/voice-openai.e2e.test.js.map +1 -1
- package/dist/voice.d.ts +1 -1
- package/dist/voice.js +72 -24
- package/dist/voice.js.map +1 -1
- package/dist/working-directory.e2e.test.js +11 -11
- package/dist/working-directory.e2e.test.js.map +1 -1
- package/package.json +12 -6
- package/src/agent-ui.e2e.test.ts +27 -13
- package/src/agent-ui.ts +2 -1
- package/src/analytics.e2e.test.ts +1 -1
- package/src/autostart.ts +55 -10
- package/src/bot.ts +29 -5
- package/src/btw.e2e.test.ts +3 -3
- package/src/builtin-skills.test.ts +3 -4
- package/src/builtin-skills.ts +1 -1
- package/src/catalog-refresh.e2e.test.ts +2 -2
- package/src/cli/bot.ts +23 -14
- package/src/cli/discord.ts +30 -6
- package/src/cli/schedule.ts +23 -9
- package/src/cli/session.ts +35 -12
- package/src/cli/shared.ts +43 -8
- package/src/cli-discord.e2e.test.ts +13 -8
- package/src/cli-lock.e2e.test.ts +88 -20
- package/src/cli-read.e2e.test.ts +17 -8
- package/src/cli-sessions.e2e.test.ts +4 -3
- package/src/cli.ts +2 -0
- package/src/commands/preference-commands.ts +38 -4
- package/src/commands/session-commands.ts +12 -7
- package/src/commands/thread-commands.ts +92 -24
- package/src/commands/worktree-commands.ts +10 -9
- package/src/commands.e2e.test.ts +25 -21
- package/src/coordinator-agents.e2e.test.ts +145 -0
- package/src/coordinator-idle.e2e.test.ts +122 -0
- package/src/coordinator-queue.e2e.test.ts +279 -0
- package/src/coordinator-warmup.e2e.test.ts +158 -0
- package/src/coordinator.e2e.test.ts +545 -0
- package/src/coordinator.ts +168 -0
- package/src/db.test.ts +1 -0
- package/src/diagram.test.ts +56 -0
- package/src/diagram.ts +243 -0
- package/src/duration.ts +13 -5
- package/src/effects.ts +99 -27
- package/src/event-loop.ts +71 -42
- package/src/format-parts.ts +53 -22
- package/src/gateway-replay.e2e.test.ts +4 -4
- package/src/harness.e2e.test.ts +2 -2
- package/src/hello-thread.e2e.test.ts +59 -5
- package/src/ingress.ts +44 -11
- package/src/interaction-context.ts +12 -11
- package/src/interrupt.e2e.test.ts +8 -5
- package/src/legacy.e2e.test.ts +1 -1
- package/src/lock-routes.ts +62 -8
- package/src/lock-server.ts +23 -7
- package/src/main.ts +22 -7
- package/src/markdown/markdown.test.ts +4 -4
- package/src/markdown/render-markdown.ts +6 -5
- package/src/onboarding-cli.e2e.test.ts +7 -6
- package/src/onboarding.e2e.test.ts +8 -10
- package/src/onboarding.ts +13 -29
- package/src/permissions.e2e.test.ts +3 -3
- package/src/plugin/coordinator.ts +401 -0
- package/src/plugin/index.ts +68 -15
- package/src/plugin.e2e.test.ts +1 -1
- package/src/preference-commands.e2e.test.ts +103 -23
- package/src/prompt.ts +87 -43
- package/src/questions.e2e.test.ts +13 -13
- package/src/queue-sources.e2e.test.ts +197 -0
- package/src/queue.e2e.test.ts +48 -30
- package/src/queue.test.ts +59 -0
- package/src/queue.ts +104 -24
- package/src/remote-send.e2e.test.ts +6 -6
- package/src/remote-send.ts +2 -1
- package/src/renderer.e2e.test.ts +7 -7
- package/src/restart.e2e.test.ts +2 -2
- package/src/routines.e2e.test.ts +2 -2
- package/src/scheduler.e2e.test.ts +45 -19
- package/src/scheduler.ts +4 -2
- package/src/schema-sql.ts +1 -1
- package/src/schema.ts +13 -0
- package/src/session-commands.e2e.test.ts +38 -11
- package/src/session-events.ts +160 -21
- package/src/session-ops.e2e.test.ts +64 -11
- package/src/sessions.ts +214 -64
- package/src/shell.e2e.test.ts +4 -4
- package/src/slash-commands.test.ts +2 -0
- package/src/slash-commands.ts +6 -4
- package/src/sleep.e2e.test.ts +30 -13
- package/src/sleeps.ts +20 -3
- package/src/store.ts +5 -2
- package/src/system-prompt.ts +143 -63
- package/src/test/generate-log-plugin/index.ts +16 -0
- package/src/test/harness.ts +77 -14
- package/src/test/replay.ts +12 -6
- package/src/thread-reducer.fixtures.test.ts +58 -51
- package/src/thread-reducer.test.ts +207 -26
- package/src/thread-reducer.ts +138 -37
- package/src/voice-attachments.e2e.test.ts +110 -18
- package/src/voice-call.e2e.test.ts +9 -9
- package/src/voice-call.ts +6 -6
- package/src/voice-openai.e2e.test.ts +64 -24
- package/src/voice.ts +93 -26
- package/src/working-directory.e2e.test.ts +11 -11
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The coordinator plugin warmup (plugin/coordinator.ts) with a short window: an idle
|
|
2
|
+
// coordinator with no running threads ends its warmup at the first check and is compacted.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { afterAll, beforeAll, expect, test } from 'vitest'
|
|
7
|
+
|
|
8
|
+
import type { BotHandle } from './main.ts'
|
|
9
|
+
import {
|
|
10
|
+
TEST_MODEL,
|
|
11
|
+
TEST_USER_ID,
|
|
12
|
+
seedProjectChannel,
|
|
13
|
+
startOpencodeTestServer,
|
|
14
|
+
startTestBot,
|
|
15
|
+
startTwin,
|
|
16
|
+
tempDataDir,
|
|
17
|
+
textParts,
|
|
18
|
+
waitFor,
|
|
19
|
+
waitForFooter,
|
|
20
|
+
warmUp,
|
|
21
|
+
type OpencodeTestServer,
|
|
22
|
+
type TestTwin,
|
|
23
|
+
} from './test/harness.ts'
|
|
24
|
+
|
|
25
|
+
let server: OpencodeTestServer
|
|
26
|
+
let twin: TestTwin
|
|
27
|
+
let bot: BotHandle
|
|
28
|
+
const dataDir = tempDataDir()
|
|
29
|
+
// A large context, so the compaction is worth it (plugin/coordinator.ts MIN_COMPACT_TOKENS),
|
|
30
|
+
// in a 1M window like Claude Opus: only 12% of it, far below automatic compaction.
|
|
31
|
+
const bigUsage = { inputTokens: 120_000, outputTokens: 1, totalTokens: 120_001 }
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
;[server, twin] = await Promise.all([
|
|
35
|
+
startOpencodeTestServer({
|
|
36
|
+
models: { [TEST_MODEL]: { name: TEST_MODEL, limit: { context: 1_000_000, output: 32_000 } } },
|
|
37
|
+
matchers: [
|
|
38
|
+
// OpenCode rejects a summary without the template headings.
|
|
39
|
+
{ id: 'compaction-summary', priority: 900, when: { latestUserTextIncludes: 'You MUST use this format for your response' }, then: { parts: textParts('## Objective\n- Keep coordinating') } },
|
|
40
|
+
{
|
|
41
|
+
id: 'hello',
|
|
42
|
+
priority: 100,
|
|
43
|
+
when: { latestUserTextIncludes: 'idle-marker' },
|
|
44
|
+
then: { parts: [{ type: 'text-start', id: 't' }, { type: 'text-delta', id: 't', delta: 'Hi there.' }, { type: 'text-end', id: 't' }, { type: 'finish', finishReason: 'stop', usage: bigUsage }] },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
startTwin(),
|
|
49
|
+
])
|
|
50
|
+
await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
|
|
51
|
+
await warmUp({ server })
|
|
52
|
+
bot = await startTestBot({ dataDir, twin, server, coordinator: true, warmupTimers: { intervalMs: 1_200, maxMs: 60_000 } })
|
|
53
|
+
}, 60_000)
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await bot?.stop()
|
|
57
|
+
await twin?.stop()
|
|
58
|
+
await server?.stop()
|
|
59
|
+
fs.rmSync(dataDir, { recursive: true, force: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('an idle coordinator gets compacted when its warmup ends', async () => {
|
|
63
|
+
const { discord, channelId } = twin
|
|
64
|
+
await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Hello idle-marker' })
|
|
65
|
+
await waitForFooter({ discord, threadId: channelId })
|
|
66
|
+
const client = await server.client()
|
|
67
|
+
const root = bot.store.getState().roots[channelId]!
|
|
68
|
+
await waitFor({
|
|
69
|
+
label: 'compaction of the idle coordinator',
|
|
70
|
+
check: async () => (await client.message.list({ sessionID: root, order: 'desc', limit: 10 })).data.some((message) => message.type === 'compaction') || null,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(await discord.channel(channelId).text()).toMatchInlineSnapshot(`
|
|
74
|
+
"--- from: user (tommy)
|
|
75
|
+
Hello idle-marker
|
|
76
|
+
--- from: assistant (TestBot)
|
|
77
|
+
-# *using deterministic-provider/deterministic-v2 ⋅ build*
|
|
78
|
+
-# Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with \`--coordinator false\`.
|
|
79
|
+
Hi there.
|
|
80
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
81
|
+
`)
|
|
82
|
+
const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 20 })
|
|
83
|
+
expect(messages.data.map((message) => message.type)).toMatchInlineSnapshot(`
|
|
84
|
+
[
|
|
85
|
+
"synthetic",
|
|
86
|
+
"synthetic",
|
|
87
|
+
"user",
|
|
88
|
+
"assistant",
|
|
89
|
+
"idle",
|
|
90
|
+
"compaction",
|
|
91
|
+
"idle",
|
|
92
|
+
]
|
|
93
|
+
`)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// OpenCode reloads the plugin when its files change (every Kimaki build or upgrade).
|
|
97
|
+
test('a plugin reload keeps the pending compaction', async () => {
|
|
98
|
+
const { discord, channelId } = twin
|
|
99
|
+
const client = await server.client()
|
|
100
|
+
const root = bot.store.getState().roots[channelId]!
|
|
101
|
+
const compactions = async () => (await client.message.list({ sessionID: root, order: 'desc', limit: 20 })).data.filter((message) => message.type === 'compaction').length
|
|
102
|
+
expect(await compactions()).toBe(1)
|
|
103
|
+
await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Again idle-marker' })
|
|
104
|
+
await waitForFooter({ discord, threadId: channelId, count: 2 })
|
|
105
|
+
fs.appendFileSync(path.join(server.configDir, 'plugins', 'kimaki', 'index.js'), '// reload\n')
|
|
106
|
+
await waitFor({ label: 'compaction after the plugin reload', check: async () => (await compactions()) === 2 || null })
|
|
107
|
+
|
|
108
|
+
const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 30 })
|
|
109
|
+
const firstCompaction = messages.data.findIndex((message) => message.type === 'compaction')
|
|
110
|
+
expect(messages.data.slice(firstCompaction + 1).map((message) => message.type)).toMatchInlineSnapshot(`
|
|
111
|
+
[
|
|
112
|
+
"idle",
|
|
113
|
+
"synthetic",
|
|
114
|
+
"synthetic",
|
|
115
|
+
"user",
|
|
116
|
+
"assistant",
|
|
117
|
+
"idle",
|
|
118
|
+
"compaction",
|
|
119
|
+
"idle",
|
|
120
|
+
]
|
|
121
|
+
`)
|
|
122
|
+
})
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// The queue in a coordinator channel: the channel itself is the conversation, so
|
|
2
|
+
// acks and "Queued message sent" replies land in the channel. Every queued
|
|
3
|
+
// prompt that got an ack is marked when it starts, also when the coordinator
|
|
4
|
+
// answers IDLE; its answer is then not a second reply to the same message.
|
|
5
|
+
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
import type { DeterministicMatcher } from 'opencode-deterministic-provider'
|
|
11
|
+
import { afterAll, beforeAll, expect, test } from 'vitest'
|
|
12
|
+
|
|
13
|
+
import type { BotHandle } from './main.ts'
|
|
14
|
+
import {
|
|
15
|
+
TEST_USER_ID,
|
|
16
|
+
isFooter,
|
|
17
|
+
seedProjectChannel,
|
|
18
|
+
slowTextMatcher,
|
|
19
|
+
startOpencodeTestServer,
|
|
20
|
+
startTestBot,
|
|
21
|
+
startTwin,
|
|
22
|
+
tempDataDir,
|
|
23
|
+
textParts,
|
|
24
|
+
waitFor,
|
|
25
|
+
waitForBotMessageContaining,
|
|
26
|
+
waitForFooter,
|
|
27
|
+
warmUp,
|
|
28
|
+
type OpencodeTestServer,
|
|
29
|
+
type TestTwin,
|
|
30
|
+
} from './test/harness.ts'
|
|
31
|
+
|
|
32
|
+
const exec = promisify(execFile)
|
|
33
|
+
|
|
34
|
+
function reply(marker: string, text: string): DeterministicMatcher {
|
|
35
|
+
return { id: marker, priority: 50, when: { latestUserTextIncludes: marker }, then: { parts: textParts(text) } }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const matchers: DeterministicMatcher[] = [
|
|
39
|
+
slowTextMatcher({ marker: 'slow-marker', text: 'slow-done', delayMs: 1_200 }),
|
|
40
|
+
reply('queued-idle', 'IDLE'),
|
|
41
|
+
reply('queued-one', 'queued one ok'),
|
|
42
|
+
reply('queued-two', 'queued two ok'),
|
|
43
|
+
reply('steer-marker', 'steer ok'),
|
|
44
|
+
reply('idle-marker', 'idle ok'),
|
|
45
|
+
reply('hello-marker', 'hello'),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
let server: OpencodeTestServer
|
|
49
|
+
let twin: TestTwin
|
|
50
|
+
let bot: BotHandle
|
|
51
|
+
const dataDir = tempDataDir()
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
;[server, twin] = await Promise.all([startOpencodeTestServer({ matchers }), startTwin()])
|
|
55
|
+
await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
|
|
56
|
+
await warmUp({ server })
|
|
57
|
+
bot = await startTestBot({ dataDir, twin, server, coordinator: true })
|
|
58
|
+
// The coordinator session exists before any test: its banner is not part of one test's snapshot.
|
|
59
|
+
await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'Start hello-marker' })
|
|
60
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId })
|
|
61
|
+
}, 60_000)
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await bot?.stop()
|
|
65
|
+
await twin?.stop()
|
|
66
|
+
await server?.stop()
|
|
67
|
+
fs.rmSync(dataDir, { recursive: true, force: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// The channel holds every test's turns: the text from the user message that starts with `content`.
|
|
71
|
+
async function channelTextFrom(content: string): Promise<string> {
|
|
72
|
+
const text = await twin.discord.channel(twin.channelId).text({ showReplies: true })
|
|
73
|
+
return text.slice(text.lastIndexOf('--- from:', text.lastIndexOf(`\n${content}`)))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function footers(): Promise<number> {
|
|
77
|
+
return twin.discord.channel(twin.channelId).getMessages().then((messages) => messages.filter(isFooter).length)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Sends a slow prompt and waits until the coordinator runs it.
|
|
81
|
+
async function startSlowTurn(content: string) {
|
|
82
|
+
const channel = twin.discord.channel(twin.channelId)
|
|
83
|
+
channel.clearTypingEvents()
|
|
84
|
+
await channel.user(TEST_USER_ID).sendMessage({ content: `${content} slow-marker` })
|
|
85
|
+
await channel.waitForTypingEvent()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function waitForIdle() {
|
|
89
|
+
await waitFor({ label: 'coordinator idle', check: async () => !bot.store.getState().threads[twin.channelId]?.turn || null })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function rootSession(): string {
|
|
93
|
+
const sessionID = bot.store.getState().roots[twin.channelId]
|
|
94
|
+
if (!sessionID) throw new Error('no coordinator session')
|
|
95
|
+
return sessionID
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
test('. queue in a busy coordinator: each queued message gets a reply when it starts, also when it answers IDLE', async () => {
|
|
99
|
+
const before = await footers()
|
|
100
|
+
await startSlowTurn('First a long one')
|
|
101
|
+
const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
|
|
102
|
+
const silent = await user.sendMessage({ content: 'Start a worker queued-idle. queue' })
|
|
103
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
|
|
104
|
+
const answered = await user.sendMessage({ content: 'Then answer queued-two. queue' })
|
|
105
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued two ok' })
|
|
106
|
+
// The IDLE turn posts no footer: slow-done and queued two get one each.
|
|
107
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 2 })
|
|
108
|
+
expect(await channelTextFrom('First a long one')).toMatchInlineSnapshot(`
|
|
109
|
+
"--- from: user (tommy)
|
|
110
|
+
First a long one slow-marker
|
|
111
|
+
Start a worker queued-idle. queue
|
|
112
|
+
--- from: assistant (TestBot)
|
|
113
|
+
[reply to tommy: Start a worker queued-idle. queue]
|
|
114
|
+
-# Queued
|
|
115
|
+
--- from: user (tommy)
|
|
116
|
+
Then answer queued-two. queue
|
|
117
|
+
--- from: assistant (TestBot)
|
|
118
|
+
[reply to tommy: Then answer queued-two. queue]
|
|
119
|
+
-# Queued
|
|
120
|
+
[reply to tommy: First a long one slow-marker]
|
|
121
|
+
slow-done
|
|
122
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
123
|
+
[reply to tommy: Start a worker queued-idle. queue]
|
|
124
|
+
-# Queued message sent
|
|
125
|
+
[reply to tommy: Then answer queued-two. queue]
|
|
126
|
+
-# Queued message sent
|
|
127
|
+
queued two ok
|
|
128
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
129
|
+
`)
|
|
130
|
+
const messages = await twin.discord.channel(twin.channelId).getMessages()
|
|
131
|
+
const sent = messages.filter((message) => message.content === '-# Queued message sent')
|
|
132
|
+
expect(sent.map((message) => message.message_reference?.message_id)).toEqual([silent.id, answered.id])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('/queue and /clear-queue in a coordinator', async () => {
|
|
136
|
+
const before = await footers()
|
|
137
|
+
await startSlowTurn('Slash queue here')
|
|
138
|
+
const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
|
|
139
|
+
await user.runSlashCommand({ name: 'queue', options: [{ name: 'message', type: 3, value: 'Slash queued-one' }] })
|
|
140
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
|
|
141
|
+
await user.runSlashCommand({ name: 'queue', options: [{ name: 'message', type: 3, value: 'Slash queued-two' }] })
|
|
142
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 2' })
|
|
143
|
+
await user.runSlashCommand({ name: 'clear-queue', options: [{ name: 'position', type: 4, value: 1 }] })
|
|
144
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued two ok' })
|
|
145
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 2 })
|
|
146
|
+
expect(await channelTextFrom('Slash queue here')).toMatchInlineSnapshot(`
|
|
147
|
+
"--- from: user (tommy)
|
|
148
|
+
Slash queue here slow-marker
|
|
149
|
+
--- from: assistant (TestBot)
|
|
150
|
+
-# » **tommy:** Slash queued-one
|
|
151
|
+
[reply to TestBot: -# » **tommy:** Slash queued-one]
|
|
152
|
+
-# Removed from queue
|
|
153
|
+
-# » **tommy:** Slash queued-two
|
|
154
|
+
[reply to TestBot: -# » **tommy:** Slash queued-two]
|
|
155
|
+
-# Queued
|
|
156
|
+
-# Cleared 1 queued message
|
|
157
|
+
[reply to tommy: Slash queue here slow-marker]
|
|
158
|
+
slow-done
|
|
159
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
160
|
+
[reply to TestBot: -# » **tommy:** Slash queued-two]
|
|
161
|
+
-# Queued message sent
|
|
162
|
+
queued two ok
|
|
163
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
164
|
+
`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('deleting a queued coordinator message removes it from the queue', async () => {
|
|
168
|
+
const before = await footers()
|
|
169
|
+
await startSlowTurn('Delete in coordinator')
|
|
170
|
+
const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
|
|
171
|
+
const queued = await user.sendMessage({ content: 'Never mind queued-one. queue' })
|
|
172
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
|
|
173
|
+
await user.deleteMessage({ messageId: queued.id })
|
|
174
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'Removed from queue' })
|
|
175
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 1 })
|
|
176
|
+
await waitForIdle()
|
|
177
|
+
expect(await channelTextFrom('Delete in coordinator')).toMatchInlineSnapshot(`
|
|
178
|
+
"--- from: user (tommy)
|
|
179
|
+
Delete in coordinator slow-marker
|
|
180
|
+
--- from: assistant (TestBot)
|
|
181
|
+
[reply to unknown message]
|
|
182
|
+
-# Removed from queue
|
|
183
|
+
[reply to tommy: Delete in coordinator slow-marker]
|
|
184
|
+
slow-done
|
|
185
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
186
|
+
`)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('kimaki send . queue and a TUI prompt queued on the coordinator show their text, then a reply when they run', async () => {
|
|
190
|
+
const before = await footers()
|
|
191
|
+
await startSlowTurn('Other sources')
|
|
192
|
+
await exec(process.execPath, ['--import', 'tsx', path.resolve('src/cli.ts'), 'send', '--session', rootSession(), '--prompt', 'From the CLI queued-one. queue', '--data-dir', dataDir], {
|
|
193
|
+
env: { ...process.env, KIMAKI_LOCK_PORT: String(bot.lock.port) },
|
|
194
|
+
})
|
|
195
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
|
|
196
|
+
await (await server.client()).session.prompt({ sessionID: rootSession(), text: 'From the TUI queued-two', delivery: 'queue' })
|
|
197
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued two ok' })
|
|
198
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 3 })
|
|
199
|
+
expect(await channelTextFrom('Other sources')).toMatchInlineSnapshot(`
|
|
200
|
+
"--- from: user (tommy)
|
|
201
|
+
Other sources slow-marker
|
|
202
|
+
--- from: assistant (TestBot)
|
|
203
|
+
-# » **CLI:** From the CLI queued-one
|
|
204
|
+
-# » **user:** From the TUI queued-two
|
|
205
|
+
[reply to tommy: Other sources slow-marker]
|
|
206
|
+
slow-done
|
|
207
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
208
|
+
[reply to TestBot: -# » **CLI:** From the CLI queued-one]
|
|
209
|
+
-# Queued message sent
|
|
210
|
+
queued one ok
|
|
211
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
212
|
+
[reply to TestBot: -# » **user:** From the TUI queued-two]
|
|
213
|
+
-# Queued message sent
|
|
214
|
+
queued two ok
|
|
215
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
216
|
+
`)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('a plain message in a busy coordinator runs first, then the queued one gets its reply', async () => {
|
|
220
|
+
const before = await footers()
|
|
221
|
+
await startSlowTurn('Steer the coordinator')
|
|
222
|
+
const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
|
|
223
|
+
await user.sendMessage({ content: 'Afterwards queued-one. queue' })
|
|
224
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
|
|
225
|
+
await user.sendMessage({ content: 'Right now steer-marker' })
|
|
226
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued one ok' })
|
|
227
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 2 })
|
|
228
|
+
expect(await channelTextFrom('Steer the coordinator')).toMatchInlineSnapshot(`
|
|
229
|
+
"--- from: user (tommy)
|
|
230
|
+
Steer the coordinator slow-marker
|
|
231
|
+
Afterwards queued-one. queue
|
|
232
|
+
--- from: assistant (TestBot)
|
|
233
|
+
[reply to tommy: Afterwards queued-one. queue]
|
|
234
|
+
-# Queued
|
|
235
|
+
--- from: user (tommy)
|
|
236
|
+
Right now steer-marker
|
|
237
|
+
--- from: assistant (TestBot)
|
|
238
|
+
[reply to tommy: Right now steer-marker]
|
|
239
|
+
steer ok
|
|
240
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
241
|
+
[reply to tommy: Afterwards queued-one. queue]
|
|
242
|
+
-# Queued message sent
|
|
243
|
+
queued one ok
|
|
244
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
245
|
+
`)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('/abort in a coordinator stops the run and clears the queue', async () => {
|
|
249
|
+
await startSlowTurn('Abort the coordinator')
|
|
250
|
+
const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
|
|
251
|
+
await user.sendMessage({ content: 'Never runs queued-one. queue' })
|
|
252
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
|
|
253
|
+
await user.runSlashCommand({ name: 'abort' })
|
|
254
|
+
await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'Removed from queue' })
|
|
255
|
+
await waitForIdle()
|
|
256
|
+
expect(await channelTextFrom('Abort the coordinator')).toMatchInlineSnapshot(`
|
|
257
|
+
"--- from: user (tommy)
|
|
258
|
+
Abort the coordinator slow-marker
|
|
259
|
+
Never runs queued-one. queue
|
|
260
|
+
--- from: assistant (TestBot)
|
|
261
|
+
[reply to tommy: Never runs queued-one. queue]
|
|
262
|
+
-# Removed from queue
|
|
263
|
+
Request **aborted**, cleared 1 queued message"
|
|
264
|
+
`)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('. queue on an idle coordinator runs at once: the answer replies to the message', async () => {
|
|
268
|
+
const before = await footers()
|
|
269
|
+
await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'Nothing waits idle-marker. queue' })
|
|
270
|
+
await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 1 })
|
|
271
|
+
expect(await channelTextFrom('Nothing waits idle-marker')).toMatchInlineSnapshot(`
|
|
272
|
+
"--- from: user (tommy)
|
|
273
|
+
Nothing waits idle-marker. queue
|
|
274
|
+
--- from: assistant (TestBot)
|
|
275
|
+
[reply to tommy: Nothing waits idle-marker. queue]
|
|
276
|
+
idle ok
|
|
277
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
278
|
+
`)
|
|
279
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The coordinator warmup (plugin/coordinator.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
|
+
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { afterAll, beforeAll, expect, test } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import type { BotHandle } from './main.ts'
|
|
10
|
+
import {
|
|
11
|
+
TEST_MODEL,
|
|
12
|
+
TEST_USER_ID,
|
|
13
|
+
seedProjectChannel,
|
|
14
|
+
startOpencodeTestServer,
|
|
15
|
+
startTestBot,
|
|
16
|
+
startTwin,
|
|
17
|
+
tempDataDir,
|
|
18
|
+
textParts,
|
|
19
|
+
toolParts,
|
|
20
|
+
waitFor,
|
|
21
|
+
waitForFooter,
|
|
22
|
+
warmUp,
|
|
23
|
+
type OpencodeTestServer,
|
|
24
|
+
type TestTwin,
|
|
25
|
+
} from './test/harness.ts'
|
|
26
|
+
|
|
27
|
+
let server: OpencodeTestServer
|
|
28
|
+
let twin: TestTwin
|
|
29
|
+
let bot: BotHandle
|
|
30
|
+
const dataDir = tempDataDir()
|
|
31
|
+
// Big enough to be worth compacting (plugin/coordinator.ts MIN_COMPACT_TOKENS).
|
|
32
|
+
const bigUsage = { inputTokens: 120_000, outputTokens: 1, totalTokens: 120_001 }
|
|
33
|
+
const WARM_INTERVAL_MS = 400
|
|
34
|
+
const WARM_MAX_MS = 1_500
|
|
35
|
+
|
|
36
|
+
function bigText(text: string) {
|
|
37
|
+
return [
|
|
38
|
+
{ type: 'text-start', id: 't' },
|
|
39
|
+
{ type: 'text-delta', id: 't', delta: text },
|
|
40
|
+
{ type: 'text-end', id: 't' },
|
|
41
|
+
{ type: 'finish', finishReason: 'stop', usage: bigUsage },
|
|
42
|
+
] as const
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The coordinator starts a worker thread, then answers IDLE.
|
|
46
|
+
function delegate({ marker, worker, channelId }: { marker: string; worker: string; channelId: string }) {
|
|
47
|
+
const command = `kimaki send --channel ${channelId} --name 'Worker ${worker}' --prompt '${worker} run' --parent-session "$OPENCODE_SESSION_ID"`
|
|
48
|
+
return [
|
|
49
|
+
{
|
|
50
|
+
id: `${marker}-send`,
|
|
51
|
+
priority: 100,
|
|
52
|
+
when: { latestUserTextIncludes: marker },
|
|
53
|
+
then: { parts: toolParts({ toolCallId: `${marker}-call`, toolName: 'shell', input: { command, description: 'Start the worker', hasSideEffect: true } }) },
|
|
54
|
+
},
|
|
55
|
+
{ id: `${marker}-idle`, priority: 101, when: { latestUserTextIncludes: marker, rawPromptIncludes: `${marker}-call` }, then: { parts: [...bigText('IDLE')] } },
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
twin = await startTwin()
|
|
61
|
+
server = await startOpencodeTestServer({
|
|
62
|
+
plugins: [path.resolve('src/test/generate-log-plugin')],
|
|
63
|
+
models: { [TEST_MODEL]: { name: TEST_MODEL, limit: { context: 1_000_000, output: 32_000 } } },
|
|
64
|
+
matchers: [
|
|
65
|
+
// OpenCode rejects a summary without the template headings.
|
|
66
|
+
{ id: 'compaction-summary', priority: 900, when: { latestUserTextIncludes: 'You MUST use this format for your response' }, then: { parts: textParts('## Objective\n- Keep coordinating') } },
|
|
67
|
+
...delegate({ marker: 'keepwarm-marker', worker: 'quickjob-marker', channelId: twin.channelId }),
|
|
68
|
+
...delegate({ marker: 'capped-marker', worker: 'stuckjob-marker', channelId: twin.channelId }),
|
|
69
|
+
{ id: 'quickjob', priority: 100, when: { latestUserTextIncludes: 'quickjob-marker' }, then: { parts: textParts('Quick job done.'), partDelaysMs: [0, 1_200, 0, 0] } },
|
|
70
|
+
{ id: 'stuckjob', priority: 100, when: { latestUserTextIncludes: 'stuckjob-marker' }, then: { parts: textParts('Stuck job done.'), partDelaysMs: [0, 3_000, 0, 0] } },
|
|
71
|
+
{ id: 'woken', priority: 300, when: { latestUserTextIncludes: '<session-finished' }, then: { parts: [...bigText('The worker finished.')] } },
|
|
72
|
+
],
|
|
73
|
+
})
|
|
74
|
+
await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
|
|
75
|
+
await warmUp({ server })
|
|
76
|
+
bot = await startTestBot({ dataDir, twin, server, coordinator: true, warmupTimers: { intervalMs: WARM_INTERVAL_MS, maxMs: WARM_MAX_MS } })
|
|
77
|
+
}, 60_000)
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await bot?.stop()
|
|
81
|
+
await twin?.stop()
|
|
82
|
+
await server?.stop()
|
|
83
|
+
fs.rmSync(dataDir, { recursive: true, force: true })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Keep-alive requests of a session, from the test plugin.
|
|
87
|
+
function keepAlives(sessionId: string): number {
|
|
88
|
+
const file = path.join(server.root, 'generate.log')
|
|
89
|
+
if (!fs.existsSync(file)) return 0
|
|
90
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter((line) => line === sessionId).length
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function compactions(sessionId: string): Promise<number> {
|
|
94
|
+
const client = await server.client()
|
|
95
|
+
const messages = await client.message.list({ sessionID: sessionId, order: 'desc', limit: 50 })
|
|
96
|
+
return messages.data.filter((message) => message.type === 'compaction').length
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('the coordinator stays warm while its worker runs, then compacts', async () => {
|
|
100
|
+
const { discord, channelId } = twin
|
|
101
|
+
await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Start keepwarm-marker' })
|
|
102
|
+
const root = await waitFor({ label: 'coordinator session', check: async () => bot.store.getState().roots[channelId] })
|
|
103
|
+
await waitForFooter({ discord, threadId: channelId })
|
|
104
|
+
await waitFor({ label: 'compaction after the wake', check: async () => (await compactions(root)) === 1 || null })
|
|
105
|
+
|
|
106
|
+
expect(await discord.channel(channelId).text()).toMatchInlineSnapshot(`
|
|
107
|
+
"--- from: user (tommy)
|
|
108
|
+
Start keepwarm-marker
|
|
109
|
+
--- from: assistant (TestBot)
|
|
110
|
+
-# *using deterministic-provider/deterministic-v2 ⋅ build*
|
|
111
|
+
-# Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with \`--coordinator false\`.
|
|
112
|
+
-# ┣ shell _Start the worker_
|
|
113
|
+
The worker finished.
|
|
114
|
+
-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
115
|
+
`)
|
|
116
|
+
// The worker runs 1.2s, a keep-alive is due every 0.4s.
|
|
117
|
+
expect(keepAlives(root)).toBeGreaterThan(0)
|
|
118
|
+
const client = await server.client()
|
|
119
|
+
const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 30 })
|
|
120
|
+
// No compaction before the wake: the channel was busy.
|
|
121
|
+
expect(messages.data.map((message) => message.type)).toMatchInlineSnapshot(`
|
|
122
|
+
[
|
|
123
|
+
"synthetic",
|
|
124
|
+
"synthetic",
|
|
125
|
+
"user",
|
|
126
|
+
"assistant",
|
|
127
|
+
"assistant",
|
|
128
|
+
"idle",
|
|
129
|
+
"synthetic",
|
|
130
|
+
"assistant",
|
|
131
|
+
"idle",
|
|
132
|
+
"compaction",
|
|
133
|
+
"idle",
|
|
134
|
+
]
|
|
135
|
+
`)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('a stuck worker stops the warmup at the cap', async () => {
|
|
139
|
+
const { discord, channelId } = twin
|
|
140
|
+
const root = bot.store.getState().roots[channelId]!
|
|
141
|
+
const before = keepAlives(root)
|
|
142
|
+
await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Start capped-marker' })
|
|
143
|
+
const worker = await waitFor({
|
|
144
|
+
label: 'stuck worker thread',
|
|
145
|
+
check: async () => (await discord.channel(channelId).getThreads()).find((thread) => thread.name?.includes('stuckjob')),
|
|
146
|
+
})
|
|
147
|
+
// The cap ends the warmup while the worker still runs: compaction, no footer in the worker yet.
|
|
148
|
+
await waitFor({ label: 'compaction at the cap', check: async () => (await compactions(root)) === 2 || null })
|
|
149
|
+
const workerText = await discord.thread(worker.id).text()
|
|
150
|
+
const atCap = keepAlives(root)
|
|
151
|
+
await waitForFooter({ discord, threadId: worker.id })
|
|
152
|
+
|
|
153
|
+
expect(workerText).not.toContain('-# *main')
|
|
154
|
+
// 1.5s cap, one keep-alive every 0.4s: at most 3, none after the cap.
|
|
155
|
+
expect(atCap - before).toBeGreaterThan(0)
|
|
156
|
+
expect(atCap - before).toBeLessThanOrEqual(3)
|
|
157
|
+
expect(keepAlives(root)).toBe(atCap)
|
|
158
|
+
})
|