macro-agent 0.1.3 → 0.1.5

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.
@@ -52,7 +52,15 @@ function createMockBackend(session?: CognitiveAgentSession): MacroAgentBackend {
52
52
  }
53
53
 
54
54
  describe("isWorkspaceExecuteMessage", () => {
55
- it("should return true for workspace.execute messages", () => {
55
+ it("should return true for x-workspace/task.execute", () => {
56
+ expect(
57
+ isWorkspaceExecuteMessage({
58
+ method: "x-workspace/task.execute",
59
+ }),
60
+ ).toBe(true);
61
+ });
62
+
63
+ it("should return true for legacy x-openhive/learning.workspace.execute", () => {
56
64
  expect(
57
65
  isWorkspaceExecuteMessage({
58
66
  method: "x-openhive/learning.workspace.execute",
@@ -111,7 +119,7 @@ describe("handleWorkspaceExecute", () => {
111
119
  expect(sentMessages.length).toBe(1);
112
120
  const result = sentMessages[0] as any;
113
121
  expect(result.jsonrpc).toBe("2.0");
114
- expect(result.method).toBe("x-openhive/learning.workspace.result");
122
+ expect(result.method).toBe("x-workspace/task.result");
115
123
  expect(result.params.request_id).toBe("req-001");
116
124
  expect(result.params.success).toBe(true);
117
125
  expect(result.params.duration_ms).toBeGreaterThanOrEqual(0);
@@ -1,20 +1,22 @@
1
1
  /**
2
2
  * Workspace Execution Handler
3
3
  *
4
- * Bridge between OpenHive's workspace.execute MAP messages and
4
+ * Bridge between workspace task execution MAP messages and
5
5
  * macro-agent's MacroAgentBackend. Receives workspace tasks from
6
- * OpenHive, spawns analyst agents, and sends results back.
6
+ * a hub, spawns analyst agents, and sends results back.
7
7
  *
8
8
  * Registered as a MAP notification handler on the swarm's inbound
9
- * WebSocket connection to the OpenHive hub.
9
+ * WebSocket connection to the hub.
10
10
  *
11
- * Protocol:
12
- * Hive → Swarm: x-openhive/learning.workspace.execute
11
+ * Protocol (defined by agent-workspace):
12
+ * Hub → Swarm: x-workspace/task.execute
13
13
  * { request_id, prompt, cwd, system_context, timeout }
14
- * Swarm → Hive: x-openhive/learning.workspace.result
14
+ * Swarm → Hub: x-workspace/task.result
15
15
  * { request_id, success, output, structured, duration_ms }
16
16
  */
17
17
 
18
+ import { WORKSPACE_METHODS, WORKSPACE_METHODS_LEGACY } from "agent-workspace";
19
+ import type { WorkspaceExecuteParams, WorkspaceResultParams } from "agent-workspace";
18
20
  import type { MacroAgentBackend } from "./macro-agent-backend.js";
19
21
  import type { CognitiveAgentSpawnConfig } from "./types.js";
20
22
 
@@ -24,13 +26,8 @@ export interface WorkspaceHandlerDeps {
24
26
  sendToHub: (message: object) => void;
25
27
  }
26
28
 
27
- export interface WorkspaceExecuteParams {
28
- request_id: string;
29
- prompt: string;
30
- cwd: string;
31
- system_context?: string;
32
- timeout?: number;
33
- }
29
+ // Re-export the protocol type for consumers
30
+ export type { WorkspaceExecuteParams } from "agent-workspace";
34
31
 
35
32
  /**
36
33
  * Handle an incoming workspace.execute request from OpenHive.
@@ -79,7 +76,7 @@ export async function handleWorkspaceExecute(
79
76
  await backend.terminate(session.id).catch(() => {});
80
77
  sendToHub({
81
78
  jsonrpc: "2.0",
82
- method: "x-openhive/learning.workspace.result",
79
+ method: WORKSPACE_METHODS.RESULT,
83
80
  params: {
84
81
  request_id,
85
82
  success: false,
@@ -128,7 +125,7 @@ export async function handleWorkspaceExecute(
128
125
 
129
126
  sendToHub({
130
127
  jsonrpc: "2.0",
131
- method: "x-openhive/learning.workspace.result",
128
+ method: WORKSPACE_METHODS.RESULT,
132
129
  params: {
133
130
  request_id,
134
131
  success: finalSession.state === "completed",
@@ -141,7 +138,7 @@ export async function handleWorkspaceExecute(
141
138
  } catch (err) {
142
139
  sendToHub({
143
140
  jsonrpc: "2.0",
144
- method: "x-openhive/learning.workspace.result",
141
+ method: WORKSPACE_METHODS.RESULT,
145
142
  params: {
146
143
  request_id,
147
144
  success: false,
@@ -158,6 +155,6 @@ export async function handleWorkspaceExecute(
158
155
  */
159
156
  export function isWorkspaceExecuteMessage(
160
157
  msg: { method?: string },
161
- ): msg is { method: "x-openhive/learning.workspace.execute"; params: WorkspaceExecuteParams } {
162
- return msg.method === "x-openhive/learning.workspace.execute";
158
+ ): boolean {
159
+ return msg.method === WORKSPACE_METHODS.EXECUTE || msg.method === WORKSPACE_METHODS_LEGACY.EXECUTE;
163
160
  }
@@ -0,0 +1,598 @@
1
+ /**
2
+ * Tests for Coordination Handler — inbound MAP task messages + notifications.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from "vitest";
6
+ import {
7
+ setupCoordinationHandlers,
8
+ type CoordinationConnection,
9
+ type CoordinationDeps,
10
+ type MAPMessage,
11
+ } from "../coordination-handler.js";
12
+ import type { InboxAdapter, TasksAdapter } from "../../adapters/types.js";
13
+
14
+ // =============================================================================
15
+ // Mock helpers
16
+ // =============================================================================
17
+
18
+ type MessageHandler = (message: MAPMessage) => void | Promise<void>;
19
+ type NotificationHandler = (params: unknown) => void | Promise<void>;
20
+
21
+ function mockConnection() {
22
+ const messageHandlers = new Set<MessageHandler>();
23
+ const notificationHandlers = new Map<string, Set<NotificationHandler>>();
24
+
25
+ const conn: CoordinationConnection = {
26
+ onMessage(handler: MessageHandler) {
27
+ messageHandlers.add(handler);
28
+ },
29
+ offMessage(handler: MessageHandler) {
30
+ messageHandlers.delete(handler);
31
+ },
32
+ onNotification(method: string, handler: NotificationHandler) {
33
+ if (!notificationHandlers.has(method)) {
34
+ notificationHandlers.set(method, new Set());
35
+ }
36
+ notificationHandlers.get(method)!.add(handler);
37
+ },
38
+ offNotification(method: string, handler: NotificationHandler) {
39
+ notificationHandlers.get(method)?.delete(handler);
40
+ },
41
+ sendNotification: vi.fn().mockResolvedValue(undefined),
42
+ };
43
+
44
+ return {
45
+ conn,
46
+ messageHandlers,
47
+ notificationHandlers,
48
+ /** Simulate an incoming MAP scope message */
49
+ async emitMessage(payload: Record<string, unknown>) {
50
+ const msg: MAPMessage = {
51
+ id: `msg-${Date.now()}`,
52
+ from: "hub",
53
+ to: { scope: "swarm:test" },
54
+ timestamp: new Date().toISOString(),
55
+ payload,
56
+ };
57
+ for (const handler of messageHandlers) {
58
+ await handler(msg);
59
+ }
60
+ },
61
+ /** Simulate an incoming JSON-RPC notification */
62
+ async emitNotification(method: string, params: unknown) {
63
+ const handlers = notificationHandlers.get(method);
64
+ if (handlers) {
65
+ for (const handler of handlers) {
66
+ await handler(params);
67
+ }
68
+ }
69
+ },
70
+ };
71
+ }
72
+
73
+ function mockInboxAdapter() {
74
+ return {
75
+ send: vi.fn().mockResolvedValue("msg-1"),
76
+ } as unknown as InboxAdapter;
77
+ }
78
+
79
+ function mockTasksAdapter() {
80
+ return {
81
+ createTask: vi.fn().mockResolvedValue("task-100"),
82
+ assignTask: vi.fn().mockResolvedValue(undefined),
83
+ transitionTask: vi.fn().mockResolvedValue(undefined),
84
+ connected: true,
85
+ } as unknown as TasksAdapter;
86
+ }
87
+
88
+ function createDeps(overrides: Partial<CoordinationDeps> = {}): CoordinationDeps & {
89
+ mock: ReturnType<typeof mockConnection>;
90
+ } {
91
+ const mock = mockConnection();
92
+ return {
93
+ connection: mock.conn,
94
+ inboxAdapter: mockInboxAdapter(),
95
+ tasksAdapter: mockTasksAdapter(),
96
+ ...overrides,
97
+ mock,
98
+ };
99
+ }
100
+
101
+ // =============================================================================
102
+ // Tests — Task messages (MAP scope messages)
103
+ // =============================================================================
104
+
105
+ describe("CoordinationHandler — task messages", () => {
106
+ let deps: ReturnType<typeof createDeps>;
107
+
108
+ beforeEach(() => {
109
+ deps = createDeps();
110
+ setupCoordinationHandlers(deps);
111
+ });
112
+
113
+ describe("task.created", () => {
114
+ it("creates task in opentasks", async () => {
115
+ await deps.mock.emitMessage({
116
+ type: "task.created",
117
+ task: { id: "t-1", title: "Fix bug", status: "open", assignee: "agent-1" },
118
+ });
119
+
120
+ expect(deps.tasksAdapter.createTask).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ title: "Fix bug",
123
+ assignee: "agent-1",
124
+ }),
125
+ );
126
+ });
127
+
128
+ it("notifies assignee via inbox", async () => {
129
+ await deps.mock.emitMessage({
130
+ type: "task.created",
131
+ task: { id: "t-1", title: "Fix bug", status: "open", assignee: "agent-1" },
132
+ });
133
+
134
+ expect(deps.inboxAdapter.send).toHaveBeenCalledWith(
135
+ "system",
136
+ "agent-1",
137
+ expect.objectContaining({
138
+ type: "event",
139
+ event: "TASK_ASSIGNED",
140
+ data: expect.objectContaining({ title: "Fix bug" }),
141
+ }),
142
+ );
143
+ });
144
+
145
+ it("skips inbox notification when no assignee", async () => {
146
+ await deps.mock.emitMessage({
147
+ type: "task.created",
148
+ task: { id: "t-1", title: "Unassigned task", status: "open" },
149
+ });
150
+
151
+ expect(deps.tasksAdapter.createTask).toHaveBeenCalled();
152
+ expect(deps.inboxAdapter.send).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("passes task description to opentasks", async () => {
156
+ await deps.mock.emitMessage({
157
+ type: "task.created",
158
+ task: { id: "t-1", title: "Fix bug", status: "open", description: "Segfault on startup" },
159
+ });
160
+
161
+ expect(deps.tasksAdapter.createTask).toHaveBeenCalledWith(
162
+ expect.objectContaining({
163
+ title: "Fix bug",
164
+ content: "Segfault on startup",
165
+ }),
166
+ );
167
+ });
168
+
169
+ it("ignores messages without a title", async () => {
170
+ await deps.mock.emitMessage({
171
+ type: "task.created",
172
+ task: { id: "t-1", status: "open" },
173
+ });
174
+
175
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("ignores messages without a task object", async () => {
179
+ await deps.mock.emitMessage({ type: "task.created" });
180
+
181
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+
185
+ describe("task.assigned", () => {
186
+ it("assigns task in opentasks and notifies agent", async () => {
187
+ await deps.mock.emitMessage({
188
+ type: "task.assigned",
189
+ taskId: "t-1",
190
+ assignee: "agent-2",
191
+ });
192
+
193
+ expect(deps.tasksAdapter.assignTask).toHaveBeenCalledWith("t-1", "agent-2");
194
+ expect(deps.inboxAdapter.send).toHaveBeenCalledWith(
195
+ "system",
196
+ "agent-2",
197
+ expect.objectContaining({
198
+ event: "TASK_ASSIGNED",
199
+ data: expect.objectContaining({ taskId: "t-1" }),
200
+ }),
201
+ );
202
+ });
203
+
204
+ it("ignores messages without taskId", async () => {
205
+ await deps.mock.emitMessage({
206
+ type: "task.assigned",
207
+ assignee: "agent-2",
208
+ });
209
+
210
+ expect(deps.tasksAdapter.assignTask).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it("ignores messages without assignee", async () => {
214
+ await deps.mock.emitMessage({
215
+ type: "task.assigned",
216
+ taskId: "t-1",
217
+ });
218
+
219
+ expect(deps.tasksAdapter.assignTask).not.toHaveBeenCalled();
220
+ });
221
+ });
222
+
223
+ describe("task.status", () => {
224
+ it("transitions task to in_progress", async () => {
225
+ await deps.mock.emitMessage({
226
+ type: "task.status",
227
+ taskId: "t-1",
228
+ previous: "open",
229
+ current: "in_progress",
230
+ });
231
+
232
+ expect(deps.tasksAdapter.transitionTask).toHaveBeenCalledWith("t-1", "start");
233
+ });
234
+
235
+ it("transitions task to completed", async () => {
236
+ await deps.mock.emitMessage({
237
+ type: "task.status",
238
+ taskId: "t-1",
239
+ previous: "in_progress",
240
+ current: "completed",
241
+ });
242
+
243
+ expect(deps.tasksAdapter.transitionTask).toHaveBeenCalledWith("t-1", "complete");
244
+ });
245
+
246
+ it("transitions task to blocked", async () => {
247
+ await deps.mock.emitMessage({
248
+ type: "task.status",
249
+ taskId: "t-1",
250
+ previous: "in_progress",
251
+ current: "blocked",
252
+ });
253
+
254
+ expect(deps.tasksAdapter.transitionTask).toHaveBeenCalledWith("t-1", "block");
255
+ });
256
+
257
+ it("transitions task to failed", async () => {
258
+ await deps.mock.emitMessage({
259
+ type: "task.status",
260
+ taskId: "t-1",
261
+ previous: "in_progress",
262
+ current: "failed",
263
+ });
264
+
265
+ expect(deps.tasksAdapter.transitionTask).toHaveBeenCalledWith("t-1", "fail");
266
+ });
267
+
268
+ it("transitions closed to complete", async () => {
269
+ await deps.mock.emitMessage({
270
+ type: "task.status",
271
+ taskId: "t-1",
272
+ previous: "in_progress",
273
+ current: "closed",
274
+ });
275
+
276
+ expect(deps.tasksAdapter.transitionTask).toHaveBeenCalledWith("t-1", "complete");
277
+ });
278
+
279
+ it("reopens task", async () => {
280
+ await deps.mock.emitMessage({
281
+ type: "task.status",
282
+ taskId: "t-1",
283
+ previous: "blocked",
284
+ current: "open",
285
+ });
286
+
287
+ expect(deps.tasksAdapter.transitionTask).toHaveBeenCalledWith("t-1", "reopen");
288
+ });
289
+
290
+ it("ignores unknown status values", async () => {
291
+ await deps.mock.emitMessage({
292
+ type: "task.status",
293
+ taskId: "t-1",
294
+ previous: "open",
295
+ current: "unknown_status",
296
+ });
297
+
298
+ expect(deps.tasksAdapter.transitionTask).not.toHaveBeenCalled();
299
+ });
300
+
301
+ it("ignores messages without taskId", async () => {
302
+ await deps.mock.emitMessage({
303
+ type: "task.status",
304
+ current: "completed",
305
+ });
306
+
307
+ expect(deps.tasksAdapter.transitionTask).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it("ignores messages without current status", async () => {
311
+ await deps.mock.emitMessage({
312
+ type: "task.status",
313
+ taskId: "t-1",
314
+ });
315
+
316
+ expect(deps.tasksAdapter.transitionTask).not.toHaveBeenCalled();
317
+ });
318
+ });
319
+
320
+ describe("echo prevention", () => {
321
+ it("skips messages with _origin macro-agent", async () => {
322
+ await deps.mock.emitMessage({
323
+ type: "task.created",
324
+ task: { id: "t-1", title: "My own task", status: "open" },
325
+ _origin: "macro-agent",
326
+ });
327
+
328
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it("processes messages from other origins", async () => {
332
+ await deps.mock.emitMessage({
333
+ type: "task.created",
334
+ task: { id: "t-1", title: "External task", status: "open" },
335
+ _origin: "cc-swarm",
336
+ });
337
+
338
+ expect(deps.tasksAdapter.createTask).toHaveBeenCalled();
339
+ });
340
+
341
+ it("processes messages with no origin", async () => {
342
+ await deps.mock.emitMessage({
343
+ type: "task.created",
344
+ task: { id: "t-1", title: "No origin task", status: "open" },
345
+ });
346
+
347
+ expect(deps.tasksAdapter.createTask).toHaveBeenCalled();
348
+ });
349
+ });
350
+
351
+ describe("error handling", () => {
352
+ it("warns on tasksAdapter failure without throwing", async () => {
353
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
354
+ (deps.tasksAdapter.createTask as ReturnType<typeof vi.fn>).mockRejectedValue(
355
+ new Error("opentasks down"),
356
+ );
357
+
358
+ await deps.mock.emitMessage({
359
+ type: "task.created",
360
+ task: { id: "t-1", title: "Will fail", status: "open" },
361
+ });
362
+
363
+ expect(warnSpy).toHaveBeenCalledWith(
364
+ expect.stringContaining("opentasks down"),
365
+ );
366
+ warnSpy.mockRestore();
367
+ });
368
+
369
+ it("continues processing after inbox send failure", async () => {
370
+ (deps.inboxAdapter.send as ReturnType<typeof vi.fn>).mockRejectedValue(
371
+ new Error("inbox error"),
372
+ );
373
+
374
+ // Should not throw — inbox errors are caught
375
+ await deps.mock.emitMessage({
376
+ type: "task.created",
377
+ task: { id: "t-1", title: "Task", status: "open", assignee: "agent-1" },
378
+ });
379
+
380
+ expect(deps.tasksAdapter.createTask).toHaveBeenCalled();
381
+ });
382
+ });
383
+
384
+ describe("ignored message types", () => {
385
+ it("ignores task.completed (informational only)", async () => {
386
+ await deps.mock.emitMessage({
387
+ type: "task.completed",
388
+ taskId: "t-1",
389
+ });
390
+
391
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
392
+ expect(deps.tasksAdapter.transitionTask).not.toHaveBeenCalled();
393
+ });
394
+
395
+ it("ignores messages without payload", async () => {
396
+ const msg: MAPMessage = {
397
+ id: "msg-1",
398
+ from: "hub",
399
+ to: { scope: "swarm:test" },
400
+ timestamp: new Date().toISOString(),
401
+ };
402
+ for (const handler of deps.mock.messageHandlers) {
403
+ await handler(msg);
404
+ }
405
+
406
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it("ignores messages without type field", async () => {
410
+ await deps.mock.emitMessage({ taskId: "t-1", status: "open" });
411
+
412
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
413
+ });
414
+ });
415
+ });
416
+
417
+ // =============================================================================
418
+ // Tests — Context and messaging NOT handled here (agent-inbox flow)
419
+ // =============================================================================
420
+
421
+ describe("CoordinationHandler — context/messaging excluded", () => {
422
+ let deps: ReturnType<typeof createDeps>;
423
+
424
+ beforeEach(() => {
425
+ deps = createDeps();
426
+ setupCoordinationHandlers(deps);
427
+ });
428
+
429
+ it("does not handle context.shared messages (handled by agent-inbox)", async () => {
430
+ await deps.mock.emitMessage({
431
+ type: "context.shared",
432
+ context_type: "code_review",
433
+ data: { file: "main.ts" },
434
+ source_swarm_id: "swarm-A",
435
+ });
436
+
437
+ // No task adapter calls
438
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
439
+ expect(deps.tasksAdapter.transitionTask).not.toHaveBeenCalled();
440
+ // No inbox calls from coordination handler
441
+ expect(deps.inboxAdapter.send).not.toHaveBeenCalled();
442
+ });
443
+
444
+ it("does not handle message type messages (handled by agent-inbox)", async () => {
445
+ await deps.mock.emitMessage({
446
+ type: "message",
447
+ from_swarm_id: "swarm-A",
448
+ to_swarm_id: "agent-1",
449
+ content_type: "text",
450
+ content: "Hello",
451
+ });
452
+
453
+ expect(deps.tasksAdapter.createTask).not.toHaveBeenCalled();
454
+ expect(deps.inboxAdapter.send).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it("does not register x-openhive/context.share notification handler", () => {
458
+ expect(deps.mock.notificationHandlers.has("x-openhive/context.share")).toBe(false);
459
+ });
460
+
461
+ it("does not register x-openhive/message.send notification handler", () => {
462
+ expect(deps.mock.notificationHandlers.has("x-openhive/message.send")).toBe(false);
463
+ });
464
+
465
+ it("does not register x-openhive/task.assign notification handler", () => {
466
+ expect(deps.mock.notificationHandlers.has("x-openhive/task.assign")).toBe(false);
467
+ });
468
+
469
+ it("does not register x-openhive/task.status notification handler", () => {
470
+ expect(deps.mock.notificationHandlers.has("x-openhive/task.status")).toBe(false);
471
+ });
472
+ });
473
+
474
+ // =============================================================================
475
+ // Tests — Workspace notifications
476
+ // =============================================================================
477
+
478
+ describe("CoordinationHandler — workspace notifications", () => {
479
+ let deps: ReturnType<typeof createDeps>;
480
+
481
+ describe("workspace.execute", () => {
482
+ it("delegates to workspace handler via x-workspace/task.execute", async () => {
483
+ const handleWorkspaceExecute = vi.fn().mockResolvedValue(undefined);
484
+ deps = createDeps({
485
+ workspaceHandler: {
486
+ handleWorkspaceExecute,
487
+ isWorkspaceExecuteMessage: (msg) => msg.method === "x-workspace/task.execute",
488
+ },
489
+ });
490
+ setupCoordinationHandlers(deps);
491
+
492
+ const params = { request_id: "req-1", prompt: "Do thing", cwd: "/tmp" };
493
+ await deps.mock.emitNotification("x-workspace/task.execute", params);
494
+
495
+ expect(handleWorkspaceExecute).toHaveBeenCalledWith(params);
496
+ });
497
+
498
+ it("delegates legacy x-openhive/learning.workspace.execute", async () => {
499
+ const handleWorkspaceExecute = vi.fn().mockResolvedValue(undefined);
500
+ deps = createDeps({
501
+ workspaceHandler: {
502
+ handleWorkspaceExecute,
503
+ isWorkspaceExecuteMessage: () => true,
504
+ },
505
+ });
506
+ setupCoordinationHandlers(deps);
507
+
508
+ const params = { request_id: "req-2", prompt: "Legacy task", cwd: "/tmp" };
509
+ await deps.mock.emitNotification("x-openhive/learning.workspace.execute", params);
510
+
511
+ expect(handleWorkspaceExecute).toHaveBeenCalledWith(params);
512
+ });
513
+
514
+ it("warns on workspace handler failure without throwing", async () => {
515
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
516
+ const handleWorkspaceExecute = vi.fn().mockRejectedValue(new Error("spawn failed"));
517
+ deps = createDeps({
518
+ workspaceHandler: {
519
+ handleWorkspaceExecute,
520
+ isWorkspaceExecuteMessage: () => true,
521
+ },
522
+ });
523
+ setupCoordinationHandlers(deps);
524
+
525
+ await deps.mock.emitNotification("x-workspace/task.execute", {
526
+ request_id: "req-3",
527
+ prompt: "fail",
528
+ cwd: "/tmp",
529
+ });
530
+
531
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("spawn failed"));
532
+ warnSpy.mockRestore();
533
+ });
534
+
535
+ it("does not register workspace handlers when no workspace handler provided", () => {
536
+ deps = createDeps();
537
+ setupCoordinationHandlers(deps);
538
+
539
+ expect(deps.mock.notificationHandlers.has("x-workspace/task.execute")).toBe(false);
540
+ expect(deps.mock.notificationHandlers.has("x-openhive/learning.workspace.execute")).toBe(false);
541
+ });
542
+ });
543
+ });
544
+
545
+ // =============================================================================
546
+ // Tests — Handler registration and cleanup
547
+ // =============================================================================
548
+
549
+ describe("CoordinationHandler — cleanup", () => {
550
+ it("removes all handlers on cleanup", () => {
551
+ const deps = createDeps({
552
+ workspaceHandler: {
553
+ handleWorkspaceExecute: vi.fn(),
554
+ isWorkspaceExecuteMessage: () => true,
555
+ },
556
+ });
557
+ const cleanup = setupCoordinationHandlers(deps);
558
+
559
+ // Verify handlers were registered
560
+ expect(deps.mock.messageHandlers.size).toBe(1);
561
+ expect(deps.mock.notificationHandlers.size).toBeGreaterThan(0);
562
+
563
+ cleanup();
564
+
565
+ // All handlers removed
566
+ expect(deps.mock.messageHandlers.size).toBe(0);
567
+ for (const [, handlers] of deps.mock.notificationHandlers) {
568
+ expect(handlers.size).toBe(0);
569
+ }
570
+ });
571
+
572
+ it("only registers message handler and workspace notifications", () => {
573
+ const deps = createDeps({
574
+ workspaceHandler: {
575
+ handleWorkspaceExecute: vi.fn(),
576
+ isWorkspaceExecuteMessage: () => true,
577
+ },
578
+ });
579
+ setupCoordinationHandlers(deps);
580
+
581
+ // One message handler for task events
582
+ expect(deps.mock.messageHandlers.size).toBe(1);
583
+
584
+ // Two notification handlers: x-workspace/task.execute + legacy
585
+ const registeredMethods = [...deps.mock.notificationHandlers.keys()];
586
+ expect(registeredMethods).toContain("x-workspace/task.execute");
587
+ expect(registeredMethods).toContain("x-openhive/learning.workspace.execute");
588
+ expect(registeredMethods).toHaveLength(2);
589
+ });
590
+
591
+ it("registers only message handler when no workspace handler", () => {
592
+ const deps = createDeps();
593
+ setupCoordinationHandlers(deps);
594
+
595
+ expect(deps.mock.messageHandlers.size).toBe(1);
596
+ expect(deps.mock.notificationHandlers.size).toBe(0);
597
+ });
598
+ });