crawd 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -27,40 +27,22 @@ type ChatMessage = {
27
27
  metadata?: ChatMessageMetadata;
28
28
  };
29
29
 
30
- /** TTS provider identifier */
31
- type TtsProvider = 'openai' | 'elevenlabs' | 'tiktok';
32
- /** Turn-based reply: chat message + bot response, each with TTS audio */
30
+ /** Turn-based reply: chat message + bot response (text only, TTS handled by overlay) */
33
31
  type ReplyTurnEvent = {
34
- /** Correlation ID — overlay sends talk:done with this ID when both audios finish */
32
+ /** Correlation ID — overlay sends talk:done with this ID when finished */
35
33
  id: string;
36
34
  chat: {
37
35
  username: string;
38
36
  message: string;
39
37
  };
40
38
  botMessage: string;
41
- chatTtsUrl: string;
42
- botTtsUrl: string;
43
- /** TTS provider used for the chat audio */
44
- chatTtsProvider?: TtsProvider;
45
- /** TTS provider used for the bot audio */
46
- botTtsProvider?: TtsProvider;
47
39
  };
48
- /** Bot speech bubble with pre-generated TTS (atomic event) */
40
+ /** Bot speech bubble (text only, TTS handled by overlay) */
49
41
  type TalkEvent = {
50
- /** Correlation ID — overlay sends talk:done with this ID when audio finishes */
42
+ /** Correlation ID — overlay sends talk:done with this ID when finished */
51
43
  id: string;
52
44
  /** Bot reply text */
53
45
  message: string;
54
- /** Bot TTS audio URL */
55
- ttsUrl: string;
56
- /** TTS provider used for the bot audio */
57
- ttsProvider?: TtsProvider;
58
- /** Optional: chat message being replied to (overlay plays this first) */
59
- chat?: {
60
- message: string;
61
- username: string;
62
- ttsUrl: string;
63
- };
64
46
  };
65
47
  /** Overlay → backend acknowledgement that a talk finished playing */
66
48
  type TalkDoneEvent = {
@@ -91,4 +73,4 @@ type CrawdEvents = {
91
73
  'crawd:status': StatusEvent;
92
74
  };
93
75
 
94
- export type { ChatMessage as ChatEvent, ChatMessage, ChatMessageMetadata, ChatPlatform, CrawdEvents, McapEvent, MockChatEvent, ReplyTurnEvent, StatusEvent, SuperChatInfo, TalkDoneEvent, TalkEvent, TtsProvider };
76
+ export type { ChatMessage as ChatEvent, ChatMessage, ChatMessageMetadata, ChatPlatform, CrawdEvents, McapEvent, MockChatEvent, ReplyTurnEvent, StatusEvent, SuperChatInfo, TalkDoneEvent, TalkEvent };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "crawd",
3
3
  "name": "Crawd Livestream",
4
- "description": "crawd.bot plugin — AI agent livestreaming with TTS, chat integration, and OBS overlay",
4
+ "description": "crawd.bot plugin — AI agent livestreaming with chat integration and OBS overlay",
5
5
  "skills": ["./skills/crawd"],
6
6
  "configSchema": {
7
7
  "type": "object",
@@ -10,39 +10,10 @@
10
10
  "enabled": { "type": "boolean" },
11
11
  "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
12
12
  "bindHost": { "type": "string" },
13
- "backendUrl": { "type": "string" },
14
- "tts": {
15
- "type": "object",
16
- "additionalProperties": false,
17
- "properties": {
18
- "chat": {
19
- "type": "array",
20
- "items": {
21
- "type": "object",
22
- "additionalProperties": false,
23
- "properties": {
24
- "provider": { "type": "string", "enum": ["openai", "elevenlabs", "tiktok"] },
25
- "voice": { "type": "string" }
26
- },
27
- "required": ["provider", "voice"]
28
- }
29
- },
30
- "bot": {
31
- "type": "array",
32
- "items": {
33
- "type": "object",
34
- "additionalProperties": false,
35
- "properties": {
36
- "provider": { "type": "string", "enum": ["openai", "elevenlabs", "tiktok"] },
37
- "voice": { "type": "string" }
38
- },
39
- "required": ["provider", "voice"]
40
- }
41
- },
42
- "openaiApiKey": { "type": "string" },
43
- "elevenlabsApiKey": { "type": "string" },
44
- "tiktokSessionId": { "type": "string" }
45
- }
13
+ "autonomyMode": {
14
+ "type": "string",
15
+ "enum": ["vibe", "plan", "none"],
16
+ "description": "Autonomy mode: vibe (timed prompts), plan (goal-driven loop), or none (disabled)"
46
17
  },
47
18
  "vibe": {
48
19
  "type": "object",
@@ -52,6 +23,7 @@
52
23
  "intervalMs": { "type": "integer", "minimum": 1000 },
53
24
  "idleAfterMs": { "type": "integer", "minimum": 1000 },
54
25
  "sleepAfterIdleMs": { "type": "integer", "minimum": 1000 },
26
+ "batchWindowMs": { "type": "integer", "minimum": 1000 },
55
27
  "prompt": { "type": "string" }
56
28
  }
57
29
  },
@@ -86,16 +58,12 @@
86
58
  "enabled": { "label": "Enabled" },
87
59
  "port": { "label": "Backend Port", "placeholder": "4000" },
88
60
  "bindHost": { "label": "Bind Host", "placeholder": "0.0.0.0", "advanced": true },
89
- "backendUrl": { "label": "Backend URL", "help": "Public URL for TTS file serving", "advanced": true },
90
- "tts.chat": { "label": "Chat TTS Voices", "help": "Ordered fallback chain [{provider, voice}]" },
91
- "tts.bot": { "label": "Bot TTS Voices", "help": "Ordered fallback chain [{provider, voice}]" },
92
- "tts.openaiApiKey": { "label": "OpenAI API Key", "sensitive": true },
93
- "tts.elevenlabsApiKey": { "label": "ElevenLabs API Key", "sensitive": true },
94
- "tts.tiktokSessionId": { "label": "TikTok Session ID", "sensitive": true },
61
+ "autonomyMode": { "label": "Autonomy Mode", "help": "vibe = timed prompts, plan = goal-driven loop, none = disabled" },
95
62
  "vibe.enabled": { "label": "Vibe Mode" },
96
63
  "vibe.intervalMs": { "label": "Vibe Interval (ms)", "advanced": true },
97
64
  "vibe.idleAfterMs": { "label": "Idle After (ms)", "advanced": true },
98
65
  "vibe.sleepAfterIdleMs": { "label": "Sleep After Idle (ms)", "advanced": true },
66
+ "vibe.batchWindowMs": { "label": "Chat Batch Window (ms)", "advanced": true },
99
67
  "vibe.prompt": { "label": "Vibe Prompt", "advanced": true },
100
68
  "chat.youtube.enabled": { "label": "YouTube Chat" },
101
69
  "chat.youtube.videoId": { "label": "YouTube Video ID" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawd",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "CLI for crawd.bot - AI agent livestreaming platform",
5
5
  "type": "module",
6
6
  "types": "./dist/types.d.ts",
@@ -60,7 +60,8 @@
60
60
  "@types/ws": "^8.5.13",
61
61
  "tsup": "^8.3.5",
62
62
  "tsx": "^4.19.2",
63
- "typescript": "^5.7.2"
63
+ "typescript": "^5.7.2",
64
+ "vitest": "^4.0.18"
64
65
  },
65
66
  "openclaw": {
66
67
  "extensions": [
@@ -81,6 +82,7 @@
81
82
  "build:backend": "tsup src/backend/index.ts --format esm --out-dir dist/backend --clean",
82
83
  "build:plugin": "tsup src/plugin.ts --format esm --out-dir dist --dts",
83
84
  "build:all": "pnpm build && pnpm build:backend",
85
+ "test": "vitest run",
84
86
  "typecheck": "tsc --noEmit"
85
87
  }
86
88
  }
@@ -78,6 +78,42 @@ The coordinator manages your activity cycle through three states:
78
78
 
79
79
  When you receive a `[CRAWD:VIBE]` prompt, the coordinator is nudging you to do something autonomously. This is your self-directed time — browse, check socials, do something interesting for viewers. You don't have to speak every vibe, but you should be doing *something* visible.
80
80
 
81
+ ## Plan Mode
82
+
83
+ When the coordinator is in plan mode, you work in structured goal-driven cycles instead of timed vibes.
84
+
85
+ ### How plans work
86
+
87
+ 1. You receive a trigger (chat message, wake-up, or `[CRAWD:PLAN]` nudge)
88
+ 2. Create a plan with `plan_set` — provide a goal and ordered steps
89
+ 3. Work on the current step (browse, talk, reply)
90
+ 4. Mark it done with `plan_step_done`
91
+ 5. The coordinator immediately nudges you with `[CRAWD:PLAN]` showing the next step
92
+ 6. Repeat until all steps are done or you abandon the plan
93
+
94
+ ### Plan tools
95
+
96
+ | Tool | Purpose |
97
+ |------|---------|
98
+ | `plan_set` | Create a new plan (goal + steps). Replaces any existing plan. |
99
+ | `plan_step_done` | Mark a step as complete by index. |
100
+ | `plan_abandon` | Stop the current plan. Coordinator stops nudging. |
101
+ | `plan_get` | View current plan progress. |
102
+
103
+ ### `[CRAWD:PLAN]` signals
104
+
105
+ When you have an active plan, the coordinator sends `[CRAWD:PLAN]` nudges showing your progress and which step to work on next. These are immediate (not timed like vibes) — as soon as you finish a step, the next nudge arrives.
106
+
107
+ If you receive chat with no active plan while in plan mode, create one with `plan_set` based on recent context (chat messages, what's on screen) or come up with your own idea.
108
+
109
+ ### Plan guidelines
110
+
111
+ - **Keep plans short** (3-6 steps). Don't plan out 20 steps — plans can be replaced.
112
+ - **Steps should be concrete and completable.** "Check bitcoin price" is good. "Become the best streamer" is not.
113
+ - **Abandon plans that aren't working.** If a step is blocked or no longer relevant, use `plan_abandon` and create a new plan or go idle.
114
+ - **Plans from chat:** When viewers suggest things to do, incorporate their ideas into a plan.
115
+ - **You can replace plans.** Calling `plan_set` while a plan is active automatically abandons the old one.
116
+
81
117
  ## Coordinator Signals
82
118
 
83
119
  All coordinator messages use the `[CRAWD:*]` prefix:
@@ -86,6 +122,7 @@ All coordinator messages use the `[CRAWD:*]` prefix:
86
122
  |--------|---------|
87
123
  | `[CRAWD:CHAT]` | Batch of viewer chat messages. Reply with `livestream_reply`. |
88
124
  | `[CRAWD:VIBE]` | Autonomous activity nudge. Do something visible on stream. |
125
+ | `[CRAWD:PLAN]` | Plan mode nudge. Shows current plan progress and next step. Work on it. |
89
126
  | `[CRAWD:MISALIGNED]` | Your previous response violated the protocol. You replied with plaintext instead of using a tool. Fix your behavior — use `livestream_reply` or `livestream_talk`, then respond with `LIVESTREAM_REPLIED`. |
90
127
 
91
128
  ## Safety (non-negotiable)
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { Coordinator, type CoordinatorConfig, type CoordinatorEvent, type IClock, type TriggerAgentFn } from './coordinator.js'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Fake clock — timers are manually advanced via flush/advance */
9
+ function createMockClock(): IClock & { advance(ms: number): void; flush(): void } {
10
+ const timers: Array<{ id: number; callback: () => void; fireAt: number; interval?: number }> = []
11
+ let now = 1_000_000
12
+ let nextId = 1
13
+
14
+ const clock: IClock & { advance(ms: number): void; flush(): void } = {
15
+ now: () => now,
16
+ setTimeout(cb, ms) {
17
+ const id = nextId++
18
+ timers.push({ id, callback: cb, fireAt: now + ms })
19
+ return id as unknown as NodeJS.Timeout
20
+ },
21
+ clearTimeout(t) {
22
+ const id = t as unknown as number
23
+ const idx = timers.findIndex(tt => tt.id === id)
24
+ if (idx !== -1) timers.splice(idx, 1)
25
+ },
26
+ setInterval(cb, ms) {
27
+ const id = nextId++
28
+ timers.push({ id, callback: cb, fireAt: now + ms, interval: ms })
29
+ return id as unknown as NodeJS.Timeout
30
+ },
31
+ clearInterval(t) {
32
+ const id = t as unknown as number
33
+ const idx = timers.findIndex(tt => tt.id === id)
34
+ if (idx !== -1) timers.splice(idx, 1)
35
+ },
36
+ /** Advance time by ms and fire all timers that would have fired */
37
+ advance(ms: number) {
38
+ const target = now + ms
39
+ while (true) {
40
+ // Find earliest timer that fires before target
41
+ const ready = timers
42
+ .filter(t => t.fireAt <= target)
43
+ .sort((a, b) => a.fireAt - b.fireAt)
44
+ if (ready.length === 0) break
45
+ const t = ready[0]
46
+ now = t.fireAt
47
+ const idx = timers.indexOf(t)
48
+ if (t.interval) {
49
+ // Reschedule interval
50
+ timers[idx] = { ...t, fireAt: t.fireAt + t.interval }
51
+ } else {
52
+ timers.splice(idx, 1)
53
+ }
54
+ t.callback()
55
+ }
56
+ now = target
57
+ },
58
+ /** Fire all currently ready timers without advancing */
59
+ flush() {
60
+ const ready = timers.filter(t => t.fireAt <= now).sort((a, b) => a.fireAt - b.fireAt)
61
+ for (const t of ready) {
62
+ const idx = timers.indexOf(t)
63
+ if (idx === -1) continue
64
+ if (t.interval) {
65
+ timers[idx] = { ...t, fireAt: t.fireAt + t.interval }
66
+ } else {
67
+ timers.splice(idx, 1)
68
+ }
69
+ t.callback()
70
+ }
71
+ },
72
+ }
73
+
74
+ return clock
75
+ }
76
+
77
+ const silentLogger = { log: vi.fn(), error: vi.fn(), warn: vi.fn() }
78
+
79
+ function makeChatMessage(text: string) {
80
+ return {
81
+ id: `id-${text}`,
82
+ shortId: text.slice(0, 6),
83
+ username: 'tester',
84
+ message: text,
85
+ platform: 'youtube' as const,
86
+ timestamp: Date.now(),
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Tests
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe('Coordinator — Plan Mode', () => {
95
+ let clock: ReturnType<typeof createMockClock>
96
+ let triggerFn: ReturnType<typeof vi.fn<TriggerAgentFn>>
97
+ let events: CoordinatorEvent[]
98
+
99
+ const planConfig: Partial<CoordinatorConfig> = {
100
+ autonomyMode: 'plan',
101
+ planNudgeDelayMs: 100,
102
+ batchWindowMs: 50,
103
+ idleAfterMs: 5_000,
104
+ sleepAfterIdleMs: 5_000,
105
+ }
106
+
107
+ beforeEach(() => {
108
+ clock = createMockClock()
109
+ triggerFn = vi.fn<TriggerAgentFn>().mockResolvedValue(['LIVESTREAM_REPLIED'])
110
+ events = []
111
+ })
112
+
113
+ function createCoordinator(configOverrides?: Partial<CoordinatorConfig>) {
114
+ const coord = new Coordinator(triggerFn, { ...planConfig, ...configOverrides }, { clock, logger: silentLogger })
115
+ coord.setOnEvent(e => events.push(e))
116
+ coord.start()
117
+ return coord
118
+ }
119
+
120
+ describe('autonomyMode', () => {
121
+ it('defaults to plan from test config', () => {
122
+ const coord = createCoordinator()
123
+ expect(coord.getState().autonomyMode).toBe('plan')
124
+ coord.stop()
125
+ })
126
+
127
+ it('respects vibe mode', () => {
128
+ const coord = createCoordinator({ autonomyMode: 'vibe' })
129
+ expect(coord.getState().autonomyMode).toBe('vibe')
130
+ coord.stop()
131
+ })
132
+
133
+ it('respects none mode', () => {
134
+ const coord = createCoordinator({ autonomyMode: 'none' })
135
+ expect(coord.getState().autonomyMode).toBe('none')
136
+ coord.stop()
137
+ })
138
+ })
139
+
140
+ describe('setPlan', () => {
141
+ it('creates a plan and emits planCreated event', () => {
142
+ const coord = createCoordinator()
143
+ const plan = coord.setPlan('Do something cool', ['Step 1', 'Step 2', 'Step 3'])
144
+
145
+ expect(plan.goal).toBe('Do something cool')
146
+ expect(plan.steps).toHaveLength(3)
147
+ expect(plan.steps[0]).toEqual({ description: 'Step 1', status: 'pending' })
148
+ expect(plan.status).toBe('active')
149
+
150
+ const created = events.find(e => e.type === 'planCreated')
151
+ expect(created).toBeDefined()
152
+ expect((created as any).goal).toBe('Do something cool')
153
+ expect((created as any).stepCount).toBe(3)
154
+ coord.stop()
155
+ })
156
+
157
+ it('wakes coordinator if sleeping', () => {
158
+ const coord = createCoordinator()
159
+ expect(coord.state).toBe('sleep')
160
+
161
+ coord.setPlan('Wake up', ['Do it'])
162
+ expect(coord.state).toBe('active')
163
+ coord.stop()
164
+ })
165
+
166
+ it('abandons existing plan when setting a new one', () => {
167
+ const coord = createCoordinator()
168
+ const plan1 = coord.setPlan('First', ['Step A'])
169
+ coord.setPlan('Second', ['Step B'])
170
+
171
+ const abandoned = events.find(e => e.type === 'planAbandoned')
172
+ expect(abandoned).toBeDefined()
173
+ expect((abandoned as any).planId).toBe(plan1.id)
174
+
175
+ expect(coord.getPlan()?.goal).toBe('Second')
176
+ coord.stop()
177
+ })
178
+ })
179
+
180
+ describe('markStepDone', () => {
181
+ it('marks a step as done', () => {
182
+ const coord = createCoordinator()
183
+ coord.setPlan('Test', ['A', 'B'])
184
+
185
+ const updated = coord.markStepDone(0)
186
+ expect(updated?.steps[0].status).toBe('done')
187
+ expect(updated?.steps[1].status).toBe('pending')
188
+ expect(updated?.status).toBe('active')
189
+ coord.stop()
190
+ })
191
+
192
+ it('completes plan when all steps done', () => {
193
+ const coord = createCoordinator()
194
+ coord.setPlan('Test', ['A', 'B'])
195
+
196
+ coord.markStepDone(0)
197
+ coord.markStepDone(1)
198
+
199
+ expect(coord.getPlan()?.status).toBe('completed')
200
+ const completed = events.find(e => e.type === 'planCompleted')
201
+ expect(completed).toBeDefined()
202
+ coord.stop()
203
+ })
204
+
205
+ it('returns null for invalid index', () => {
206
+ const coord = createCoordinator()
207
+ coord.setPlan('Test', ['A'])
208
+
209
+ expect(coord.markStepDone(-1)).toBeNull()
210
+ expect(coord.markStepDone(5)).toBeNull()
211
+ coord.stop()
212
+ })
213
+
214
+ it('returns null when no active plan', () => {
215
+ const coord = createCoordinator()
216
+ expect(coord.markStepDone(0)).toBeNull()
217
+ coord.stop()
218
+ })
219
+ })
220
+
221
+ describe('abandonPlan', () => {
222
+ it('abandons active plan', () => {
223
+ const coord = createCoordinator()
224
+ coord.setPlan('Test', ['A'])
225
+
226
+ const abandoned = coord.abandonPlan()
227
+ expect(abandoned?.status).toBe('abandoned')
228
+
229
+ const event = events.filter(e => e.type === 'planAbandoned')
230
+ expect(event.length).toBeGreaterThanOrEqual(1)
231
+ coord.stop()
232
+ })
233
+
234
+ it('returns null when no active plan', () => {
235
+ const coord = createCoordinator()
236
+ expect(coord.abandonPlan()).toBeNull()
237
+ coord.stop()
238
+ })
239
+ })
240
+
241
+ describe('plan nudge loop', () => {
242
+ it('schedules nudge after flush when plan has pending steps', async () => {
243
+ const coord = createCoordinator()
244
+ coord.setPlan('Test', ['Step 1', 'Step 2'])
245
+
246
+ // Send a chat message to trigger flush
247
+ coord.onMessage(makeChatMessage('hello'))
248
+
249
+ // Wait for flush to complete
250
+ await vi.waitFor(() => expect(triggerFn).toHaveBeenCalledTimes(1))
251
+
252
+ // The flush should have triggered checkPlanProgress → schedulePlanNudge
253
+ const nudgeScheduled = events.find(e => e.type === 'planNudgeScheduled')
254
+ expect(nudgeScheduled).toBeDefined()
255
+
256
+ coord.stop()
257
+ })
258
+
259
+ it('sends [CRAWD:PLAN] prompt with plan progress', async () => {
260
+ const coord = createCoordinator()
261
+ coord.setPlan('Check BTC', ['Open tracker', 'Find price', 'Comment'])
262
+ coord.markStepDone(0)
263
+
264
+ // Advance past nudge delay to trigger planNudge
265
+ clock.advance(150)
266
+
267
+ // Wait for the nudge to fire
268
+ await vi.waitFor(() => {
269
+ const calls = triggerFn.mock.calls
270
+ return expect(calls.some((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))).toBe(true)
271
+ })
272
+
273
+ const planCall = triggerFn.mock.calls.find((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
274
+ expect(planCall).toBeDefined()
275
+ const prompt = planCall![0] as string
276
+ expect(prompt).toContain('Check BTC')
277
+ expect(prompt).toContain('[x] 0. Open tracker')
278
+ expect(prompt).toContain('[-] 1. Find price')
279
+ expect(prompt).toContain('<-- next')
280
+
281
+ coord.stop()
282
+ })
283
+
284
+ it('does not nudge when plan is completed', async () => {
285
+ const coord = createCoordinator()
286
+ coord.setPlan('Quick', ['Only step'])
287
+ coord.markStepDone(0)
288
+
289
+ expect(coord.getPlan()?.status).toBe('completed')
290
+
291
+ clock.advance(200)
292
+ await new Promise(r => setTimeout(r, 50))
293
+
294
+ // No plan nudge should have been sent
295
+ const planNudges = triggerFn.mock.calls.filter((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
296
+ expect(planNudges).toHaveLength(0)
297
+
298
+ coord.stop()
299
+ })
300
+
301
+ it('does not nudge when plan is abandoned', async () => {
302
+ const coord = createCoordinator()
303
+ coord.setPlan('Abandon me', ['Step 1', 'Step 2'])
304
+ coord.abandonPlan()
305
+
306
+ clock.advance(200)
307
+ await new Promise(r => setTimeout(r, 50))
308
+
309
+ const planNudges = triggerFn.mock.calls.filter((c: any[]) => c[0]?.includes('[CRAWD:PLAN]'))
310
+ expect(planNudges).toHaveLength(0)
311
+
312
+ coord.stop()
313
+ })
314
+
315
+ it('skips nudge when busy', async () => {
316
+ // Make trigger slow so coordinator stays busy
317
+ triggerFn.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(['LIVESTREAM_REPLIED']), 500)))
318
+
319
+ const coord = createCoordinator()
320
+ coord.setPlan('Busy test', ['Step 1', 'Step 2'])
321
+
322
+ // Trigger a chat message to make it busy
323
+ coord.onMessage(makeChatMessage('start'))
324
+
325
+ // Advance past nudge delay while flush is still in progress
326
+ clock.advance(150)
327
+
328
+ // Coordinator should handle the busy state gracefully without crashing
329
+ coord.stop()
330
+ })
331
+ })
332
+
333
+ describe('chat batch in plan mode', () => {
334
+ it('appends plan instruction to chat batch when no active plan', () => {
335
+ const coord = createCoordinator()
336
+ // No plan set
337
+
338
+ const batch = coord.formatBatch([makeChatMessage('do something cool')])
339
+ expect(batch).toContain('plan mode')
340
+ expect(batch).toContain('plan_set')
341
+
342
+ coord.stop()
343
+ })
344
+
345
+ it('does not append plan instruction when plan is active', () => {
346
+ const coord = createCoordinator()
347
+ coord.setPlan('Active plan', ['Working on it'])
348
+
349
+ const batch = coord.formatBatch([makeChatMessage('hello')])
350
+ expect(batch).not.toContain('plan mode')
351
+
352
+ coord.stop()
353
+ })
354
+ })
355
+
356
+ describe('getState includes plan', () => {
357
+ it('includes plan and autonomyMode in state', () => {
358
+ const coord = createCoordinator()
359
+ coord.setPlan('Stateful', ['Step'])
360
+
361
+ const state = coord.getState()
362
+ expect(state.autonomyMode).toBe('plan')
363
+ expect(state.plan).toBeDefined()
364
+ expect(state.plan?.goal).toBe('Stateful')
365
+
366
+ coord.stop()
367
+ })
368
+ })
369
+
370
+ describe('vibe mode not affected', () => {
371
+ it('does not schedule vibes in plan mode', () => {
372
+ const coord = createCoordinator({ autonomyMode: 'plan' })
373
+ coord.wake()
374
+
375
+ clock.advance(35_000) // past default vibe interval
376
+
377
+ const vibeScheduled = events.find(e => e.type === 'vibeScheduled')
378
+ expect(vibeScheduled).toBeUndefined()
379
+
380
+ coord.stop()
381
+ })
382
+
383
+ it('schedules vibes in vibe mode', () => {
384
+ const coord = createCoordinator({ autonomyMode: 'vibe', vibeIntervalMs: 1_000 })
385
+ coord.wake()
386
+
387
+ const vibeScheduled = events.find(e => e.type === 'vibeScheduled')
388
+ expect(vibeScheduled).toBeDefined()
389
+
390
+ coord.stop()
391
+ })
392
+ })
393
+ })