macro-agent 0.0.16 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/acp/macro-agent.d.ts +2 -0
  2. package/dist/acp/macro-agent.d.ts.map +1 -1
  3. package/dist/acp/macro-agent.js +52 -20
  4. package/dist/acp/macro-agent.js.map +1 -1
  5. package/dist/agent/agent-manager.d.ts.map +1 -1
  6. package/dist/agent/agent-manager.js +23 -5
  7. package/dist/agent/agent-manager.js.map +1 -1
  8. package/dist/map/adapter/acp-over-map.d.ts +16 -0
  9. package/dist/map/adapter/acp-over-map.d.ts.map +1 -1
  10. package/dist/map/adapter/acp-over-map.js +242 -24
  11. package/dist/map/adapter/acp-over-map.js.map +1 -1
  12. package/dist/map/adapter/map-adapter.d.ts +1 -0
  13. package/dist/map/adapter/map-adapter.d.ts.map +1 -1
  14. package/dist/map/adapter/map-adapter.js +57 -8
  15. package/dist/map/adapter/map-adapter.js.map +1 -1
  16. package/dist/store/event-store.d.ts +5 -0
  17. package/dist/store/event-store.d.ts.map +1 -1
  18. package/dist/store/event-store.js +126 -53
  19. package/dist/store/event-store.js.map +1 -1
  20. package/dist/store/instance.d.ts +0 -2
  21. package/dist/store/instance.d.ts.map +1 -1
  22. package/dist/store/instance.js +1 -24
  23. package/dist/store/instance.js.map +1 -1
  24. package/dist/store/types/agents.d.ts +5 -0
  25. package/dist/store/types/agents.d.ts.map +1 -1
  26. package/package.json +3 -3
  27. package/references/acp-factory-ref/package-lock.json +2 -2
  28. package/references/acp-factory-ref/package.json +2 -2
  29. package/references/claude-code-acp/package-lock.json +2 -2
  30. package/references/claude-code-acp/package.json +1 -1
  31. package/references/claude-code-acp/src/acp-agent.ts +3 -6
  32. package/src/acp/__tests__/history.test.ts +8 -4
  33. package/src/acp/macro-agent.ts +60 -26
  34. package/src/agent/__tests__/agent-manager.test.ts +4 -6
  35. package/src/agent/agent-manager.ts +24 -5
  36. package/src/map/adapter/__tests__/acp-over-map-cancel.test.ts +802 -0
  37. package/src/map/adapter/__tests__/acp-over-map-history.test.ts +1123 -0
  38. package/src/map/adapter/__tests__/acp-over-map-persistence.e2e.test.ts +440 -0
  39. package/src/map/adapter/acp-over-map.ts +282 -25
  40. package/src/map/adapter/map-adapter.ts +79 -9
  41. package/src/store/__tests__/event-store.test.ts +44 -12
  42. package/src/store/__tests__/instance.test.ts +5 -7
  43. package/src/store/event-store.ts +157 -57
  44. package/src/store/instance.ts +1 -29
  45. package/src/store/types/agents.ts +1 -0
@@ -0,0 +1,440 @@
1
+ /**
2
+ * ACP-over-MAP History Persistence E2E Test
3
+ *
4
+ * Tests the full lifecycle of history persistence across server restarts:
5
+ * 1. Start server → create session → send prompt (history recorded)
6
+ * 2. Shut down server
7
+ * 3. Restart server (same data directory) → reconnect → load history
8
+ *
9
+ * Uses a real file-backed EventStore to verify data survives restarts.
10
+ */
11
+
12
+ import { describe, it, expect, afterEach } from "vitest";
13
+ import { mkdtempSync, rmSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+ import { ACPOverMAPHandler } from "../acp-over-map.js";
17
+ import type { ACPEnvelope } from "../acp-over-map.js";
18
+ import { createEventStore, type EventStore } from "../../../store/event-store.js";
19
+ import type { AgentManager } from "../../../agent/agent-manager.js";
20
+ import type { TaskManager } from "../../../task/task-manager.js";
21
+ import type { Agent, Task } from "../../../store/types/index.js";
22
+ import type { AgentId } from "../../../store/types/index.js";
23
+ import { vi } from "vitest";
24
+
25
+ // ─────────────────────────────────────────────────────────────────
26
+ // Helpers
27
+ // ─────────────────────────────────────────────────────────────────
28
+
29
+ function createMockAgent(overrides: Partial<Agent> = {}): Agent {
30
+ return {
31
+ id: "agent-1" as AgentId,
32
+ session_id: "session-1",
33
+ state: "running",
34
+ task: "Test task",
35
+ task_id: "task-1",
36
+ parent: null,
37
+ lineage: [],
38
+ config: {},
39
+ cwd: "/test/cwd",
40
+ created_at: Date.now(),
41
+ started_at: Date.now(),
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function createMockTask(overrides: Partial<Task> = {}): Task {
47
+ return {
48
+ id: "task-1",
49
+ description: "Test task",
50
+ status: "in_progress",
51
+ created_by: "agent-1",
52
+ created_at: Date.now(),
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ function createMockAgentManager(promptUpdates: unknown[] = []): AgentManager {
58
+ const mockAgent = createMockAgent();
59
+
60
+ return {
61
+ spawn: vi.fn().mockResolvedValue({
62
+ id: "agent-new",
63
+ session_id: "session-new",
64
+ agent: createMockAgent({ id: "agent-new" as AgentId, session_id: "session-new" }),
65
+ session: {},
66
+ }),
67
+ get: vi.fn().mockReturnValue(mockAgent),
68
+ list: vi.fn().mockReturnValue([mockAgent]),
69
+ listHeadManagers: vi.fn().mockReturnValue([mockAgent]),
70
+ getChildren: vi.fn().mockReturnValue([]),
71
+ getHierarchy: vi.fn().mockReturnValue({
72
+ root: { agent: mockAgent, children: [] },
73
+ depth: 1,
74
+ totalAgents: 1,
75
+ }),
76
+ getOrCreateHeadManager: vi.fn().mockResolvedValue({
77
+ id: "agent-1",
78
+ session_id: "session-1",
79
+ agent: mockAgent,
80
+ session: {},
81
+ }),
82
+ hasActiveSession: vi.fn().mockReturnValue(true),
83
+ resume: vi.fn().mockResolvedValue({
84
+ id: "agent-1",
85
+ session_id: "session-1",
86
+ agent: mockAgent,
87
+ session: {},
88
+ }),
89
+ terminate: vi.fn().mockResolvedValue(undefined),
90
+ prompt: vi.fn().mockReturnValue({
91
+ [Symbol.asyncIterator]: async function* () {
92
+ for (const update of promptUpdates) {
93
+ yield update;
94
+ }
95
+ },
96
+ }),
97
+ getSession: vi.fn().mockReturnValue(null),
98
+ onLifecycleEvent: vi.fn().mockReturnValue(() => {}),
99
+ close: vi.fn().mockResolvedValue(undefined),
100
+ respondToPermission: vi.fn().mockReturnValue(true),
101
+ cancelPermission: vi.fn().mockReturnValue(true),
102
+ } as unknown as AgentManager;
103
+ }
104
+
105
+ function createMockTaskManager(): TaskManager {
106
+ return {
107
+ get: vi.fn().mockReturnValue(createMockTask()),
108
+ list: vi.fn().mockReturnValue([createMockTask()]),
109
+ create: vi.fn().mockReturnValue(createMockTask()),
110
+ } as unknown as TaskManager;
111
+ }
112
+
113
+ function envelope(
114
+ streamId: string,
115
+ method: string,
116
+ params?: unknown,
117
+ sessionId?: string,
118
+ ): ACPEnvelope {
119
+ return {
120
+ acp: {
121
+ jsonrpc: "2.0",
122
+ id: `${streamId}-${method}-${Date.now()}`,
123
+ method,
124
+ params,
125
+ },
126
+ acpContext: {
127
+ streamId,
128
+ sessionId,
129
+ direction: "client-to-agent",
130
+ },
131
+ };
132
+ }
133
+
134
+ // ─────────────────────────────────────────────────────────────────
135
+ // E2E Tests
136
+ // ─────────────────────────────────────────────────────────────────
137
+
138
+ describe("ACP-over-MAP history persistence (E2E with file-backed store)", () => {
139
+ let tmpDir: string;
140
+ const instanceId = "test-persist-instance";
141
+ const agentId = "agent-1" as AgentId;
142
+ const sessionId = "session-1";
143
+
144
+ afterEach(() => {
145
+ // Clean up temp directory
146
+ if (tmpDir) {
147
+ try {
148
+ rmSync(tmpDir, { recursive: true, force: true });
149
+ } catch {
150
+ // Ignore cleanup errors
151
+ }
152
+ }
153
+ });
154
+
155
+ /**
156
+ * Register an agent in the EventStore so loadSession can resolve it.
157
+ */
158
+ function registerAgent(eventStore: EventStore): void {
159
+ eventStore.emit({
160
+ type: "spawn",
161
+ source: { agent_id: agentId },
162
+ payload: {
163
+ agent_id: agentId,
164
+ session_id: sessionId,
165
+ task: "Test task",
166
+ task_id: "task-1",
167
+ cwd: "/test/cwd",
168
+ },
169
+ });
170
+ eventStore.emit({
171
+ type: "lifecycle",
172
+ source: { agent_id: agentId },
173
+ payload: {
174
+ agent_id: agentId,
175
+ action: "started",
176
+ },
177
+ });
178
+ }
179
+
180
+ it("should persist history across server restarts", async () => {
181
+ tmpDir = mkdtempSync(join(tmpdir(), "acp-persist-"));
182
+
183
+ // ════════════════════════════════════════════════════════════════
184
+ // LIFECYCLE 1: Create session, send prompt, record history
185
+ // ════════════════════════════════════════════════════════════════
186
+
187
+ let eventStore1 = await createEventStore({
188
+ instanceId,
189
+ baseDir: tmpDir,
190
+ });
191
+
192
+ // Register agent in the store
193
+ registerAgent(eventStore1);
194
+
195
+ const handler1 = new ACPOverMAPHandler({
196
+ agentManager: createMockAgentManager([
197
+ {
198
+ sessionUpdate: "agent_message_chunk",
199
+ content: { type: "text", text: "Hello! I'm your assistant. " },
200
+ },
201
+ {
202
+ sessionUpdate: "agent_message_chunk",
203
+ content: { type: "text", text: "How can I help you today?" },
204
+ },
205
+ {
206
+ sessionUpdate: "tool_call",
207
+ toolCallId: "tc-1",
208
+ title: "ListFiles",
209
+ status: "completed",
210
+ rawInput: { path: "/src" },
211
+ output: "index.ts\napp.ts",
212
+ },
213
+ ]),
214
+ eventStore: eventStore1,
215
+ taskManager: createMockTaskManager(),
216
+ defaultCwd: "/test/cwd",
217
+ });
218
+
219
+ // Initialize stream
220
+ const streamId1 = "lifecycle-1-stream";
221
+ await handler1.processRequest(
222
+ agentId,
223
+ envelope(streamId1, "initialize", {
224
+ protocolVersion: 1,
225
+ capabilities: {},
226
+ clientInfo: { name: "test-tui", version: "1.0" },
227
+ }),
228
+ );
229
+
230
+ // Create session
231
+ const newResult = await handler1.processRequest(
232
+ agentId,
233
+ envelope(streamId1, "session/new", {
234
+ cwd: "/test",
235
+ mcpServers: [],
236
+ }),
237
+ );
238
+ const createdSessionId = (newResult.acp.result as { sessionId?: string })?.sessionId;
239
+ expect(createdSessionId).toBe(sessionId);
240
+
241
+ // Send a prompt — this should record turns in the EventStore
242
+ const promptResult = await handler1.processRequest(
243
+ agentId,
244
+ envelope(streamId1, "session/prompt", {
245
+ prompt: [{ type: "text", text: "List the files in /src" }],
246
+ }, createdSessionId),
247
+ );
248
+ expect((promptResult.acp.result as { stopReason: string }).stopReason).toBe("end_turn");
249
+
250
+ // Verify history exists in lifecycle 1
251
+ const historyCheck = await handler1.processRequest(
252
+ agentId,
253
+ envelope(streamId1, "_macro/getHistory", { sessionId: createdSessionId }),
254
+ );
255
+ const checkTurns = (historyCheck.acp.result as { turns: unknown[] }).turns;
256
+ expect(checkTurns).toHaveLength(2); // user + assistant
257
+
258
+ // ════════════════════════════════════════════════════════════════
259
+ // SHUTDOWN: Close the EventStore (simulates server shutdown)
260
+ // ════════════════════════════════════════════════════════════════
261
+
262
+ await eventStore1.close();
263
+
264
+ // ════════════════════════════════════════════════════════════════
265
+ // LIFECYCLE 2: Restart with same data dir, reconnect, load history
266
+ // ════════════════════════════════════════════════════════════════
267
+
268
+ const eventStore2 = await createEventStore({
269
+ instanceId,
270
+ baseDir: tmpDir,
271
+ });
272
+
273
+ // Verify agent data survived restart
274
+ const restoredAgent = eventStore2.getAgent(agentId);
275
+ expect(restoredAgent).not.toBeNull();
276
+ expect(restoredAgent?.session_id).toBe(sessionId);
277
+
278
+ const handler2 = new ACPOverMAPHandler({
279
+ agentManager: createMockAgentManager(), // Fresh agent manager
280
+ eventStore: eventStore2,
281
+ taskManager: createMockTaskManager(),
282
+ defaultCwd: "/test/cwd",
283
+ });
284
+
285
+ // Initialize new stream (simulates TUI restart)
286
+ const streamId2 = "lifecycle-2-stream";
287
+ await handler2.processRequest(
288
+ agentId,
289
+ envelope(streamId2, "initialize", {
290
+ protocolVersion: 1,
291
+ capabilities: {},
292
+ clientInfo: { name: "test-tui-reconnect", version: "1.0" },
293
+ }),
294
+ );
295
+
296
+ // Load session with _resolve_ pattern (like the TUI does)
297
+ const loadResult = await handler2.processRequest(
298
+ agentId,
299
+ envelope(streamId2, "session/load", {
300
+ sessionId: "_resolve_",
301
+ cwd: "/test",
302
+ mcpServers: [],
303
+ _meta: { agentId },
304
+ }),
305
+ );
306
+
307
+ // Verify loadSession returns the resolved session ID
308
+ const resolvedSessionId = (loadResult.acp.result as { sessionId?: string })?.sessionId;
309
+ expect(resolvedSessionId).toBeDefined();
310
+ expect(resolvedSessionId).toBe(sessionId);
311
+ expect(resolvedSessionId).not.toBe("_resolve_");
312
+
313
+ // Load history using the resolved session ID (exactly like the TUI does)
314
+ const historyResult = await handler2.processRequest(
315
+ agentId,
316
+ envelope(streamId2, "_macro/getHistory", { sessionId: resolvedSessionId }),
317
+ );
318
+
319
+ // ════════════════════════════════════════════════════════════════
320
+ // VERIFY: History is restored correctly
321
+ // ════════════════════════════════════════════════════════════════
322
+
323
+ const turns = (historyResult.acp.result as {
324
+ turns: { role: string; timestamp: number; content: unknown }[];
325
+ }).turns;
326
+
327
+ expect(turns).toHaveLength(2);
328
+
329
+ // User turn
330
+ expect(turns[0].role).toBe("user");
331
+ expect(turns[0].content).toBe("List the files in /src");
332
+
333
+ // Assistant turn — accumulated text + tool call
334
+ expect(turns[1].role).toBe("assistant");
335
+ const assistantContent = turns[1].content as {
336
+ parts: { type: string; text?: string; toolCallId?: string; title?: string; output?: unknown }[];
337
+ };
338
+ expect(assistantContent.parts).toHaveLength(2);
339
+ expect(assistantContent.parts[0]).toEqual({
340
+ type: "text",
341
+ text: "Hello! I'm your assistant. How can I help you today?",
342
+ });
343
+ expect(assistantContent.parts[1]).toMatchObject({
344
+ type: "tool",
345
+ toolCallId: "tc-1",
346
+ title: "ListFiles",
347
+ status: "completed",
348
+ output: "index.ts\napp.ts",
349
+ });
350
+
351
+ // Timestamps should be in order (assistant is +1ms from user)
352
+ expect(turns[1].timestamp).toBeGreaterThanOrEqual(turns[0].timestamp);
353
+
354
+ await eventStore2.close();
355
+ }, 30000);
356
+
357
+ it("should persist multiple prompt rounds across restarts", async () => {
358
+ tmpDir = mkdtempSync(join(tmpdir(), "acp-persist-multi-"));
359
+
360
+ // ── Lifecycle 1: Two prompts ──
361
+ let eventStore1 = await createEventStore({ instanceId, baseDir: tmpDir });
362
+ registerAgent(eventStore1);
363
+
364
+ const agentManager1 = createMockAgentManager([
365
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "First reply" } },
366
+ ]);
367
+
368
+ const handler1 = new ACPOverMAPHandler({
369
+ agentManager: agentManager1,
370
+ eventStore: eventStore1,
371
+ taskManager: createMockTaskManager(),
372
+ defaultCwd: "/test/cwd",
373
+ });
374
+
375
+ const stream1 = "multi-stream-1";
376
+ await handler1.processRequest(agentId, envelope(stream1, "initialize", {
377
+ protocolVersion: 1, capabilities: {}, clientInfo: { name: "test", version: "1.0" },
378
+ }));
379
+ await handler1.processRequest(agentId, envelope(stream1, "session/new", {
380
+ cwd: "/test", mcpServers: [],
381
+ }));
382
+
383
+ // First prompt
384
+ await handler1.processRequest(agentId, envelope(stream1, "session/prompt", {
385
+ prompt: [{ type: "text", text: "Question 1" }],
386
+ }, sessionId));
387
+
388
+ // Second prompt with different response
389
+ vi.mocked(agentManager1.prompt).mockReturnValue({
390
+ [Symbol.asyncIterator]: async function* () {
391
+ yield { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Second reply" } };
392
+ },
393
+ } as any);
394
+
395
+ await handler1.processRequest(agentId, envelope(stream1, "session/prompt", {
396
+ prompt: [{ type: "text", text: "Question 2" }],
397
+ }, sessionId));
398
+
399
+ await eventStore1.close();
400
+
401
+ // ── Lifecycle 2: Verify all 4 turns survived ──
402
+ const eventStore2 = await createEventStore({ instanceId, baseDir: tmpDir });
403
+
404
+ const handler2 = new ACPOverMAPHandler({
405
+ agentManager: createMockAgentManager(),
406
+ eventStore: eventStore2,
407
+ taskManager: createMockTaskManager(),
408
+ defaultCwd: "/test/cwd",
409
+ });
410
+
411
+ const stream2 = "multi-stream-2";
412
+ await handler2.processRequest(agentId, envelope(stream2, "initialize", {
413
+ protocolVersion: 1, capabilities: {}, clientInfo: { name: "test", version: "1.0" },
414
+ }));
415
+
416
+ const loadResult = await handler2.processRequest(agentId, envelope(stream2, "session/load", {
417
+ sessionId: "_resolve_", cwd: "/test", mcpServers: [], _meta: { agentId },
418
+ }));
419
+ const resolved = (loadResult.acp.result as { sessionId?: string })?.sessionId;
420
+ expect(resolved).toBe(sessionId);
421
+
422
+ const historyResult = await handler2.processRequest(agentId, envelope(stream2, "_macro/getHistory", {
423
+ sessionId: resolved,
424
+ }));
425
+
426
+ const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
427
+ expect(turns).toHaveLength(4);
428
+ expect(turns[0].role).toBe("user");
429
+ expect(turns[0].content).toBe("Question 1");
430
+ expect(turns[1].role).toBe("assistant");
431
+ expect(turns[2].role).toBe("user");
432
+ expect(turns[2].content).toBe("Question 2");
433
+ expect(turns[3].role).toBe("assistant");
434
+
435
+ const reply2 = turns[3].content as { parts: { text: string }[] };
436
+ expect(reply2.parts[0].text).toBe("Second reply");
437
+
438
+ await eventStore2.close();
439
+ }, 30000);
440
+ });