macro-agent 0.1.4 → 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.
@@ -1,8 +1,11 @@
1
1
  /**
2
- * Tests for Trajectory Reporter — checkpoint building & reporting.
2
+ * Tests for Trajectory Reporter — checkpoint reporting & content serving.
3
3
  */
4
4
 
5
- import { describe, it, expect, beforeEach, vi } from "vitest";
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import * as os from "node:os";
6
9
  import {
7
10
  createTrajectoryReporter,
8
11
  type TrajectoryConnection,
@@ -171,3 +174,252 @@ describe("TrajectoryReporter", () => {
171
174
  );
172
175
  });
173
176
  });
177
+
178
+ // =============================================================================
179
+ // Tests — Content Serving via sessionlog
180
+ // =============================================================================
181
+
182
+ describe("TrajectoryReporter — content serving", () => {
183
+ let conn: ReturnType<typeof mockConnection>;
184
+ let tmpDir: string;
185
+
186
+ beforeEach(() => {
187
+ conn = mockConnection();
188
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "trajectory-content-test-"));
189
+ });
190
+
191
+ afterEach(() => {
192
+ fs.rmSync(tmpDir, { recursive: true, force: true });
193
+ });
194
+
195
+ /** Write a sessionlog-compatible flat state file: <sessionsDir>/<sessionId>.json */
196
+ function writeSessionState(
197
+ sessionsDir: string,
198
+ sessionId: string,
199
+ state: Record<string, unknown>,
200
+ transcript?: string,
201
+ ): string {
202
+ fs.mkdirSync(sessionsDir, { recursive: true });
203
+
204
+ const transcriptPath = path.join(sessionsDir, `${sessionId}.jsonl`);
205
+ if (transcript) {
206
+ fs.writeFileSync(transcriptPath, transcript);
207
+ }
208
+
209
+ fs.writeFileSync(
210
+ path.join(sessionsDir, `${sessionId}.json`),
211
+ JSON.stringify({
212
+ sessionID: sessionId,
213
+ phase: "active",
214
+ baseCommit: "abc123",
215
+ startedAt: new Date().toISOString(),
216
+ agentType: "claude",
217
+ transcriptPath: transcript ? transcriptPath : undefined,
218
+ ...state,
219
+ }),
220
+ );
221
+
222
+ return transcriptPath;
223
+ }
224
+
225
+ it("serves transcript from live session matching session ID", async () => {
226
+ const sessionsDir = path.join(tmpDir, "sessions");
227
+ const transcript = [
228
+ JSON.stringify({ type: "user", message: "Fix the bug" }),
229
+ JSON.stringify({ type: "assistant", message: "I'll look into it" }),
230
+ ].join("\n");
231
+
232
+ writeSessionState(sessionsDir, "sess-abc", {
233
+ stepCount: 3,
234
+ filesTouched: ["src/main.ts"],
235
+ firstPrompt: "Fix the bug",
236
+ }, transcript);
237
+
238
+ createTrajectoryReporter(conn, {
239
+ trajectorySyncLevel: "full",
240
+ sessionDirs: [sessionsDir],
241
+ });
242
+
243
+ const handler = conn.onNotification.mock.calls[0][1];
244
+ await handler({ request_id: "req-1", checkpoint_id: "sess-abc-step2" });
245
+
246
+ expect(conn.sendNotification).toHaveBeenCalledWith(
247
+ "trajectory/content.response",
248
+ expect.objectContaining({
249
+ request_id: "req-1",
250
+ transcript: expect.stringContaining("Fix the bug"),
251
+ prompts: "Fix the bug",
252
+ metadata: expect.objectContaining({
253
+ sessionID: "sess-abc",
254
+ source: "live",
255
+ }),
256
+ }),
257
+ );
258
+ });
259
+
260
+ it("serves transcript matching checkpoint ID in turnCheckpointIDs", async () => {
261
+ const sessionsDir = path.join(tmpDir, "sessions");
262
+ const transcript = JSON.stringify({ type: "user", message: "Deploy it" }) + "\n";
263
+
264
+ writeSessionState(sessionsDir, "sess-xyz", {
265
+ turnCheckpointIDs: ["sess-xyz-step1", "sess-xyz-step2"],
266
+ firstPrompt: "Deploy it",
267
+ }, transcript);
268
+
269
+ createTrajectoryReporter(conn, {
270
+ trajectorySyncLevel: "full",
271
+ sessionDirs: [sessionsDir],
272
+ });
273
+
274
+ const handler = conn.onNotification.mock.calls[0][1];
275
+ await handler({ request_id: "req-2", checkpoint_id: "sess-xyz-step2" });
276
+
277
+ expect(conn.sendNotification).toHaveBeenCalledWith(
278
+ "trajectory/content.response",
279
+ expect.objectContaining({
280
+ request_id: "req-2",
281
+ transcript: expect.stringContaining("Deploy it"),
282
+ }),
283
+ );
284
+ });
285
+
286
+ it("uses promptAttributions for multi-prompt sessions", async () => {
287
+ const sessionsDir = path.join(tmpDir, "sessions");
288
+ const transcript = JSON.stringify({ type: "user", message: "First" }) + "\n"
289
+ + JSON.stringify({ type: "user", message: "Second" }) + "\n";
290
+
291
+ writeSessionState(sessionsDir, "sess-multi", {
292
+ firstPrompt: "First",
293
+ promptAttributions: [
294
+ { prompt: "First", timestamp: "2026-01-01T00:00:00Z", agentLines: 10 },
295
+ { prompt: "Second", timestamp: "2026-01-01T00:01:00Z", agentLines: 5 },
296
+ ],
297
+ }, transcript);
298
+
299
+ createTrajectoryReporter(conn, {
300
+ trajectorySyncLevel: "full",
301
+ sessionDirs: [sessionsDir],
302
+ });
303
+
304
+ const handler = conn.onNotification.mock.calls[0][1];
305
+ await handler({ request_id: "req-3", checkpoint_id: "sess-multi-step1" });
306
+
307
+ expect(conn.sendNotification).toHaveBeenCalledWith(
308
+ "trajectory/content.response",
309
+ expect.objectContaining({
310
+ prompts: "First\n---\nSecond",
311
+ }),
312
+ );
313
+ });
314
+
315
+ it("returns empty response when no session found", async () => {
316
+ createTrajectoryReporter(conn, {
317
+ trajectorySyncLevel: "full",
318
+ sessionDirs: [path.join(tmpDir, "nonexistent")],
319
+ });
320
+
321
+ const handler = conn.onNotification.mock.calls[0][1];
322
+ await handler({ request_id: "req-4", checkpoint_id: "unknown-session-step1" });
323
+
324
+ expect(conn.sendNotification).toHaveBeenCalledWith(
325
+ "trajectory/content.response",
326
+ expect.objectContaining({
327
+ request_id: "req-4",
328
+ transcript: "",
329
+ metadata: expect.objectContaining({ source: "macro-agent" }),
330
+ }),
331
+ );
332
+ });
333
+
334
+ it("serves transcripts from ended sessions (content is still valid)", async () => {
335
+ const sessionsDir = path.join(tmpDir, "sessions");
336
+ const transcript = JSON.stringify({ type: "user", message: "Old session" }) + "\n";
337
+
338
+ writeSessionState(sessionsDir, "sess-ended", {
339
+ phase: "ended",
340
+ }, transcript);
341
+
342
+ createTrajectoryReporter(conn, {
343
+ trajectorySyncLevel: "full",
344
+ sessionDirs: [sessionsDir],
345
+ });
346
+
347
+ const handler = conn.onNotification.mock.calls[0][1];
348
+ await handler({ request_id: "req-5", checkpoint_id: "sess-ended-step1" });
349
+
350
+ expect(conn.sendNotification).toHaveBeenCalledWith(
351
+ "trajectory/content.response",
352
+ expect.objectContaining({
353
+ request_id: "req-5",
354
+ transcript: expect.stringContaining("Old session"),
355
+ }),
356
+ );
357
+ });
358
+
359
+ it("skips sessions with missing transcript path", async () => {
360
+ const sessionsDir = path.join(tmpDir, "sessions");
361
+
362
+ // Write state without transcript file
363
+ writeSessionState(sessionsDir, "sess-no-file", {});
364
+
365
+ createTrajectoryReporter(conn, {
366
+ trajectorySyncLevel: "full",
367
+ sessionDirs: [sessionsDir],
368
+ });
369
+
370
+ const handler = conn.onNotification.mock.calls[0][1];
371
+ await handler({ request_id: "req-6", checkpoint_id: "sess-no-file-step1" });
372
+
373
+ const call = conn.sendNotification.mock.calls[0];
374
+ expect(call[1]).toHaveProperty("transcript", "");
375
+ });
376
+
377
+ it("sends error response when content handler throws", async () => {
378
+ conn.sendNotification
379
+ .mockRejectedValueOnce(new Error("network"))
380
+ .mockResolvedValueOnce(undefined);
381
+
382
+ createTrajectoryReporter(conn, {
383
+ trajectorySyncLevel: "full",
384
+ sessionDirs: [path.join(tmpDir, "nonexistent")],
385
+ });
386
+
387
+ const handler = conn.onNotification.mock.calls[0][1];
388
+ await handler({ request_id: "req-7", checkpoint_id: "any" });
389
+
390
+ // First call fails, second call sends error response
391
+ expect(conn.sendNotification).toHaveBeenCalledTimes(2);
392
+ expect(conn.sendNotification).toHaveBeenLastCalledWith(
393
+ "trajectory/content.response",
394
+ expect.objectContaining({
395
+ request_id: "req-7",
396
+ error: "Content serving failed",
397
+ }),
398
+ );
399
+ });
400
+
401
+ it("searches multiple session directories", async () => {
402
+ const dir1 = path.join(tmpDir, "dir1");
403
+ const dir2 = path.join(tmpDir, "dir2");
404
+ const transcript = JSON.stringify({ type: "user", message: "Found in dir2" }) + "\n";
405
+
406
+ // Only dir2 has the session
407
+ fs.mkdirSync(dir1, { recursive: true });
408
+ writeSessionState(dir2, "sess-multi-dir", {}, transcript);
409
+
410
+ createTrajectoryReporter(conn, {
411
+ trajectorySyncLevel: "full",
412
+ sessionDirs: [dir1, dir2],
413
+ });
414
+
415
+ const handler = conn.onNotification.mock.calls[0][1];
416
+ await handler({ request_id: "req-8", checkpoint_id: "sess-multi-dir-step1" });
417
+
418
+ expect(conn.sendNotification).toHaveBeenCalledWith(
419
+ "trajectory/content.response",
420
+ expect.objectContaining({
421
+ transcript: expect.stringContaining("Found in dir2"),
422
+ }),
423
+ );
424
+ });
425
+ });
@@ -1,22 +1,26 @@
1
1
  /**
2
2
  * Coordination Handler — dispatches inbound coordination messages from the MAP hub.
3
3
  *
4
- * Handles x-openhive/* and x-workspace/* JSON-RPC notifications for task assignment,
5
- * status updates, context sharing, messaging, and workspace execution.
4
+ * Task operations use generic MAP scope messages (task.created, task.assigned,
5
+ * task.status) matching the wire format used by cc-swarm and opentasks.
6
+ * Context sharing and messaging use agent-inbox (not MAP).
7
+ * Workspace execution uses x-workspace/* notifications.
6
8
  *
7
9
  * @module map/coordination-handler
8
10
  */
9
11
 
10
12
  import { WORKSPACE_METHODS, WORKSPACE_METHODS_LEGACY } from "agent-workspace";
11
- import type { AgentManager } from "../agent/agent-manager.js";
12
13
  import type { InboxAdapter, TasksAdapter } from "../adapters/types.js";
13
- import type {
14
- CoordinationTaskAssign,
15
- CoordinationTaskStatus,
16
- CoordinationContextShare,
17
- CoordinationMessage,
18
- TrajectoryReporter,
19
- } from "./types.js";
14
+
15
+ /** MAP Message shape (subset of @multi-agent-protocol/sdk Message) */
16
+ export interface MAPMessage {
17
+ id: string;
18
+ from: string;
19
+ to: string | { scope: string };
20
+ timestamp: string;
21
+ payload?: Record<string, unknown>;
22
+ meta?: Record<string, unknown>;
23
+ }
20
24
 
21
25
  export interface CoordinationConnection {
22
26
  onNotification(
@@ -28,14 +32,15 @@ export interface CoordinationConnection {
28
32
  handler: (params: unknown) => void | Promise<void>,
29
33
  ): void;
30
34
  sendNotification(method: string, params: unknown): Promise<void>;
35
+ onMessage(handler: (message: MAPMessage) => void | Promise<void>): void;
36
+ offMessage(handler: (message: MAPMessage) => void | Promise<void>): void;
31
37
  }
32
38
 
33
39
  export interface CoordinationDeps {
34
40
  connection: CoordinationConnection;
35
- agentManager: AgentManager;
41
+ /** Used by task handlers to notify assignees */
36
42
  inboxAdapter: InboxAdapter;
37
43
  tasksAdapter: TasksAdapter;
38
- trajectoryReporter?: TrajectoryReporter;
39
44
  /** Workspace handler from cognitive module (if available) */
40
45
  workspaceHandler?: {
41
46
  handleWorkspaceExecute(params: unknown): Promise<void>;
@@ -43,160 +48,133 @@ export interface CoordinationDeps {
43
48
  };
44
49
  }
45
50
 
46
- /** Coordination method constants */
51
+ /** Notification method constants (workspace only — task/context/message use MAP messages) */
47
52
  const METHODS = {
48
- TASK_ASSIGN: "x-openhive/task.assign",
49
- TASK_STATUS: "x-openhive/task.status",
50
- CONTEXT_SHARE: "x-openhive/context.share",
51
- MESSAGE_SEND: "x-openhive/message.send",
52
53
  WORKSPACE_EXECUTE: WORKSPACE_METHODS.EXECUTE,
53
54
  WORKSPACE_EXECUTE_LEGACY: WORKSPACE_METHODS_LEGACY.EXECUTE,
54
55
  } as const;
55
56
 
56
57
  /**
57
- * Register coordination notification handlers on the MAP connection.
58
+ * Register coordination handlers on the MAP connection.
59
+ *
60
+ * Task operations are handled via MAP scope messages (onMessage).
61
+ * Context sharing and messaging are handled by agent-inbox (not here).
62
+ * Workspace execution uses x-workspace/* notifications.
58
63
  * Returns a cleanup function that removes all handlers.
59
64
  */
60
65
  export function setupCoordinationHandlers(
61
66
  deps: CoordinationDeps,
62
67
  ): () => void {
63
- const { connection, agentManager, inboxAdapter, tasksAdapter } = deps;
64
- const handlers: Array<{ method: string; handler: (params: unknown) => void | Promise<void> }> = [];
68
+ const { connection, inboxAdapter, tasksAdapter } = deps;
69
+ const notificationHandlers: Array<{ method: string; handler: (params: unknown) => void | Promise<void> }> = [];
65
70
 
66
71
  const register = (
67
72
  method: string,
68
73
  handler: (params: unknown) => void | Promise<void>,
69
74
  ): void => {
70
75
  connection.onNotification(method, handler);
71
- handlers.push({ method, handler });
76
+ notificationHandlers.push({ method, handler });
72
77
  };
73
78
 
74
- // --- Task Assignment ---
75
- register(METHODS.TASK_ASSIGN, async (params: unknown) => {
76
- const p = params as CoordinationTaskAssign;
77
- if (!p?.title) return;
79
+ // =========================================================================
80
+ // Task operations generic MAP scope messages
81
+ // Wire format matches cc-swarm / opentasks MAP Event Bridge:
82
+ // { type: "task.created", task: { id, title, status, assignee } }
83
+ // { type: "task.assigned", taskId, assignee }
84
+ // { type: "task.status", taskId, previous, current }
85
+ // =========================================================================
86
+
87
+ const messageHandler = async (message: MAPMessage): Promise<void> => {
88
+ const payload = message.payload;
89
+ if (!payload || typeof payload.type !== "string") return;
90
+
91
+ // Skip messages we originated (echo prevention)
92
+ const origin = payload._origin as string | undefined;
93
+ if (origin === "macro-agent") return;
78
94
 
79
95
  try {
80
- // Create task in opentasks
81
- const taskId = await tasksAdapter.createTask({
82
- title: p.title,
83
- content: p.description,
84
- assignee: p.assigned_to,
85
- priority: p.priority === "critical" ? 1 : p.priority === "high" ? 2 : p.priority === "low" ? 4 : 3,
86
- });
87
-
88
- // Optionally spawn an agent to work on the task
89
- if (p.assigned_to) {
90
- try {
91
- await inboxAdapter.send("system", p.assigned_to, {
92
- type: "event",
93
- event: "TASK_ASSIGNED",
94
- data: { taskId, title: p.title, description: p.description },
96
+ switch (payload.type) {
97
+ case "task.created": {
98
+ const task = payload.task as
99
+ | { id?: string; title?: string; status?: string; assignee?: string }
100
+ | undefined;
101
+ if (!task?.title) return;
102
+
103
+ const taskId = await tasksAdapter.createTask({
104
+ title: task.title,
105
+ content: (task as Record<string, unknown>).description as string | undefined,
106
+ assignee: task.assignee,
95
107
  });
96
- } catch {
97
- // Agent may not exist locally — that's fine
108
+
109
+ if (task.assignee) {
110
+ await inboxAdapter
111
+ .send("system", task.assignee, {
112
+ type: "event",
113
+ event: "TASK_ASSIGNED",
114
+ data: { taskId, title: task.title },
115
+ })
116
+ .catch(() => {});
117
+ }
118
+ break;
98
119
  }
99
- }
100
- } catch (err) {
101
- console.warn(
102
- `[map-sidecar] Failed to handle task.assign: ${(err as Error).message}`,
103
- );
104
- }
105
- });
106
120
 
107
- // --- Task Status ---
108
- register(METHODS.TASK_STATUS, async (params: unknown) => {
109
- const p = params as CoordinationTaskStatus;
110
- if (!p?.task_id || !p?.status) return;
121
+ case "task.assigned": {
122
+ const taskId = payload.taskId as string | undefined;
123
+ const assignee = payload.assignee as string | undefined;
124
+ if (!taskId || !assignee) return;
125
+
126
+ await tasksAdapter.assignTask(taskId, assignee);
127
+
128
+ await inboxAdapter
129
+ .send("system", assignee, {
130
+ type: "event",
131
+ event: "TASK_ASSIGNED",
132
+ data: { taskId },
133
+ })
134
+ .catch(() => {});
135
+ break;
136
+ }
111
137
 
112
- try {
113
- const actionMap: Record<string, string> = {
114
- in_progress: "start",
115
- completed: "complete",
116
- closed: "complete",
117
- failed: "fail",
118
- blocked: "block",
119
- open: "reopen",
120
- };
121
- const action = actionMap[p.status];
122
- if (action) {
123
- await tasksAdapter.transitionTask(p.task_id, action as any);
124
- }
125
- } catch (err) {
126
- console.warn(
127
- `[map-sidecar] Failed to handle task.status: ${(err as Error).message}`,
128
- );
129
- }
130
- });
138
+ case "task.status": {
139
+ const taskId = payload.taskId as string | undefined;
140
+ const current = payload.current as string | undefined;
141
+ if (!taskId || !current) return;
142
+
143
+ const actionMap: Record<string, string> = {
144
+ in_progress: "start",
145
+ completed: "complete",
146
+ closed: "complete",
147
+ failed: "fail",
148
+ blocked: "block",
149
+ open: "reopen",
150
+ };
151
+ const action = actionMap[current];
152
+ if (action) {
153
+ await tasksAdapter.transitionTask(taskId, action as any);
154
+ }
155
+ break;
156
+ }
131
157
 
132
- // --- Context Share ---
133
- register(METHODS.CONTEXT_SHARE, async (params: unknown) => {
134
- const p = params as CoordinationContextShare;
135
- if (!p?.context_type || !p?.data) return;
158
+ // Context sharing and messaging are handled by agent-inbox directly
159
+ // (not through MAP scope messages). See InboxAdapter for broadcast
160
+ // scope delivery and agent-to-agent messaging.
136
161
 
137
- try {
138
- // Deliver context to all running agents via inbox
139
- const agents = agentManager.list()
140
- .filter((a: any) => a.state === "running");
141
- for (const agent of agents) {
142
- await inboxAdapter
143
- .send("system", agent.id, {
144
- type: "event",
145
- event: "CONTEXT_SHARED",
146
- data: {
147
- context_type: p.context_type,
148
- data: p.data,
149
- source: p.source_swarm_id,
150
- },
151
- })
152
- .catch(() => {});
162
+ // Ignore other message types (e.g., task.completed is informational)
163
+ default:
164
+ break;
153
165
  }
154
166
  } catch (err) {
155
167
  console.warn(
156
- `[map-sidecar] Failed to handle context.share: ${(err as Error).message}`,
168
+ `[map-sidecar] Failed to handle ${payload.type}: ${(err as Error).message}`,
157
169
  );
158
170
  }
159
- });
171
+ };
160
172
 
161
- // --- Message Send ---
162
- register(METHODS.MESSAGE_SEND, async (params: unknown) => {
163
- const p = params as CoordinationMessage;
164
- if (!p?.content) return;
173
+ connection.onMessage(messageHandler);
165
174
 
166
- try {
167
- const agents = agentManager.list()
168
- .filter((a: any) => a.state === "running");
169
- if (agents.length === 0) return;
170
-
171
- // Route to the best target:
172
- // 1. If to_swarm_id matches a local agent ID, send directly
173
- // 2. If metadata has a target_agent hint, use it
174
- // 3. Otherwise, send to the coordinator/head manager (parentless agent)
175
- // 4. Fallback: first running agent
176
- const targetId = p.to_swarm_id;
177
- const directTarget = targetId
178
- ? agents.find((a: any) => a.id === targetId)
179
- : undefined;
180
- const coordinator = agents.find((a: any) => !a.parent);
181
- const target = directTarget ?? coordinator ?? agents[0];
182
-
183
- await inboxAdapter.send("system", target.id, {
184
- type: "event",
185
- event: "EXTERNAL_MESSAGE",
186
- data: {
187
- from: p.from_swarm_id,
188
- content_type: p.content_type,
189
- content: p.content,
190
- reply_to: p.reply_to,
191
- metadata: p.metadata,
192
- },
193
- });
194
- } catch (err) {
195
- console.warn(
196
- `[map-sidecar] Failed to handle message.send: ${(err as Error).message}`,
197
- );
198
- }
199
- });
175
+ // =========================================================================
176
+ // Workspace JSON-RPC notifications (x-workspace protocol)
177
+ // =========================================================================
200
178
 
201
179
  // --- Workspace Execute (delegate to cognitive module) ---
202
180
  if (deps.workspaceHandler) {
@@ -216,9 +194,10 @@ export function setupCoordinationHandlers(
216
194
 
217
195
  // Return cleanup function
218
196
  return () => {
219
- for (const { method, handler } of handlers) {
197
+ connection.offMessage(messageHandler);
198
+ for (const { method, handler } of notificationHandlers) {
220
199
  connection.offNotification(method, handler);
221
200
  }
222
- handlers.length = 0;
201
+ notificationHandlers.length = 0;
223
202
  };
224
203
  }
@@ -275,10 +275,8 @@ export function createMAPSidecar(
275
275
  const { setupCoordinationHandlers } = await import("./coordination-handler.js");
276
276
  coordinationCleanup = setupCoordinationHandlers({
277
277
  connection,
278
- agentManager,
279
278
  inboxAdapter,
280
279
  tasksAdapter,
281
- trajectoryReporter,
282
280
  workspaceHandler,
283
281
  });
284
282
  }