macro-agent 0.0.16 → 0.0.17

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.
@@ -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
+ });
@@ -277,7 +277,7 @@ export class ACPOverMAPHandler {
277
277
  streamState.agentId = existing.id;
278
278
  this.sessionMapper.createMapping(sessionId as ACPSessionId, existing.id);
279
279
  this.emitSessionInfo(streamState, sessionId, emitNotification);
280
- return {};
280
+ return { sessionId };
281
281
  }
282
282
 
283
283
  // Agent exists but no active session - resume it
@@ -287,7 +287,7 @@ export class ACPOverMAPHandler {
287
287
  streamState.agentId = spawned.id;
288
288
  this.sessionMapper.createMapping(sessionId as ACPSessionId, spawned.id);
289
289
  this.emitSessionInfo(streamState, sessionId, emitNotification);
290
- return {};
290
+ return { sessionId };
291
291
  }
292
292
 
293
293
  // No existing agent found - create new with the specified session ID
@@ -302,7 +302,7 @@ export class ACPOverMAPHandler {
302
302
  this.sessionMapper.createMapping(sessionId as ACPSessionId, spawned.id);
303
303
  this.emitSessionInfo(streamState, sessionId, emitNotification);
304
304
 
305
- return {};
305
+ return { sessionId };
306
306
  }
307
307
 
308
308
  private async handleAuthenticate(_params: unknown): Promise<unknown> {
@@ -322,7 +322,9 @@ export class ACPOverMAPHandler {
322
322
  messages?: Array<{ role: string; content: string }>;
323
323
  }) ?? {};
324
324
 
325
- const sessionId = paramSessionId ?? sessionIdFromContext ?? streamState.sessionId;
325
+ // Prefer the server's resolved session ID (set during loadSession) over the
326
+ // client's acpContext.sessionId which may be stale (e.g., "_resolve_" sentinel)
327
+ const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
326
328
  if (!sessionId) {
327
329
  throw new Error("No session - call newSession or loadSession first");
328
330
  }
@@ -380,6 +382,15 @@ export class ACPOverMAPHandler {
380
382
  emitNotification(notification);
381
383
  };
382
384
 
385
+ // Ensure conversation exists in EventStore for history persistence
386
+ this.ensureConversation(sessionId as ACPSessionId, agentId);
387
+
388
+ // Accumulate response content for history recording
389
+ const buffer: { textChunks: string[]; toolCalls: Record<string, unknown>[] } = {
390
+ textChunks: [],
391
+ toolCalls: [],
392
+ };
393
+
383
394
  try {
384
395
  // Stream responses from the agent
385
396
  let updateCount = 0;
@@ -389,6 +400,27 @@ export class ACPOverMAPHandler {
389
400
  return { stopReason: "cancelled" };
390
401
  }
391
402
 
403
+ // Accumulate content for history persistence
404
+ const u = update as Record<string, unknown>;
405
+ const updateType = u.sessionUpdate as string ?? u.type as string;
406
+ if (updateType === "agent_message_chunk") {
407
+ const content = u.content as { type?: string; text?: string } | undefined;
408
+ if (content?.text) {
409
+ buffer.textChunks.push(content.text);
410
+ }
411
+ } else if (updateType === "tool_call" || updateType === "tool_call_update") {
412
+ const status = u.status as string | undefined;
413
+ if (status === "completed" || (updateType === "tool_call" && status !== "running")) {
414
+ buffer.toolCalls.push({
415
+ toolCallId: u.toolCallId,
416
+ title: u.title,
417
+ status: u.status,
418
+ input: u.rawInput,
419
+ output: u.output,
420
+ });
421
+ }
422
+ }
423
+
392
424
  // Stream the update back to the client
393
425
  emitSessionUpdate(update);
394
426
  updateCount++;
@@ -396,6 +428,9 @@ export class ACPOverMAPHandler {
396
428
 
397
429
  console.error(`[ACP-over-MAP] Prompt completed for agent ${agentId}, ${updateCount} updates`);
398
430
 
431
+ // Persist conversation turns for history
432
+ this.recordPromptTurns(sessionId as ACPSessionId, agentId, messageContent, buffer);
433
+
399
434
  // Emit updated session info after prompt completes
400
435
  this.emitSessionInfo(streamState, sessionId, emitNotification);
401
436
 
@@ -417,7 +452,8 @@ export class ACPOverMAPHandler {
417
452
  sessionIdFromContext?: string,
418
453
  ): Promise<unknown> {
419
454
  const { sessionId: paramSessionId } = (params as { sessionId?: string }) ?? {};
420
- const sessionId = paramSessionId ?? sessionIdFromContext ?? streamState.sessionId;
455
+ // Prefer server's resolved session ID over client's potentially stale one
456
+ const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
421
457
 
422
458
  // Signal cancellation
423
459
  streamState.abortController.abort();
@@ -538,11 +574,159 @@ export class ACPOverMAPHandler {
538
574
  };
539
575
  }
540
576
 
577
+ case "_macro/getHistory": {
578
+ const { sessionId, agentId: historyAgentId, limit } = methodParams as {
579
+ sessionId?: string;
580
+ agentId?: string;
581
+ limit?: number;
582
+ };
583
+
584
+ // Resolve conversationId: prefer agentId lookup (resolves to the
585
+ // original session_id where turns were recorded), fall back to
586
+ // explicit sessionId. This allows history to survive across server
587
+ // restarts even when the ACP session ID changes (e.g., resume()
588
+ // fails → TUI creates new session with different ID).
589
+ let conversationId: string | undefined;
590
+ if (historyAgentId) {
591
+ const agent = this.eventStore.getAgent(historyAgentId as AgentId);
592
+ if (agent) {
593
+ conversationId = agent.session_id;
594
+ }
595
+ }
596
+ if (!conversationId) {
597
+ conversationId = sessionId;
598
+ }
599
+ if (!conversationId) {
600
+ return { turns: [] };
601
+ }
602
+
603
+ const turns = this.eventStore.listTurns({
604
+ conversationId,
605
+ order: "asc",
606
+ limit: limit ?? 200,
607
+ });
608
+ return {
609
+ turns: turns.map((turn) => ({
610
+ role:
611
+ turn.contentType === "user_prompt"
612
+ ? ("user" as const)
613
+ : ("assistant" as const),
614
+ timestamp: turn.timestamp,
615
+ content: turn.content,
616
+ })),
617
+ };
618
+ }
619
+
541
620
  default:
542
621
  throw new Error(`Unknown extension method: ${method}`);
543
622
  }
544
623
  }
545
624
 
625
+ // ─────────────────────────────────────────────────────────────────
626
+ // History Persistence
627
+ // ─────────────────────────────────────────────────────────────────
628
+
629
+ /**
630
+ * Ensure a conversation exists in the EventStore for a given session.
631
+ * This must be called before recording turns.
632
+ */
633
+ private ensureConversation(
634
+ acpSessionId: ACPSessionId,
635
+ agentId: AgentId,
636
+ ): void {
637
+ if (typeof this.eventStore.getConversation !== "function") {
638
+ return;
639
+ }
640
+
641
+ const existing = this.eventStore.getConversation(acpSessionId);
642
+ if (existing) return;
643
+
644
+ try {
645
+ this.eventStore.emit({
646
+ type: "conversation",
647
+ source: { agent_id: agentId },
648
+ payload: {
649
+ action: "created",
650
+ conversation_id: acpSessionId,
651
+ conversation_type: "session",
652
+ subject: `ACP session ${acpSessionId}`,
653
+ },
654
+ });
655
+ } catch (error) {
656
+ console.warn(
657
+ `[ACP-over-MAP] Failed to create conversation for session ${acpSessionId}:`,
658
+ error instanceof Error ? error.message : String(error),
659
+ );
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Record user and assistant turns after a prompt completes.
665
+ * Mirrors MacroAgent.recordPromptTurns() for the ACP-over-MAP path.
666
+ */
667
+ private recordPromptTurns(
668
+ acpSessionId: ACPSessionId,
669
+ agentId: AgentId,
670
+ userMessage: string,
671
+ buffer: { textChunks: string[]; toolCalls: Record<string, unknown>[] },
672
+ ): void {
673
+ const now = Date.now();
674
+
675
+ try {
676
+ // Record user turn
677
+ if (userMessage) {
678
+ this.eventStore.emit({
679
+ type: "turn",
680
+ source: { agent_id: agentId },
681
+ payload: {
682
+ action: "recorded",
683
+ turn_id: `turn_user_${now}_${Math.random().toString(36).slice(2, 8)}`,
684
+ conversation_id: acpSessionId,
685
+ participant: "user",
686
+ timestamp: now,
687
+ content_type: "user_prompt",
688
+ content: userMessage,
689
+ source_type: "explicit",
690
+ },
691
+ });
692
+ }
693
+
694
+ // Record assistant turn with accumulated content
695
+ const assistantText = buffer.textChunks.join("");
696
+ const parts: unknown[] = [];
697
+
698
+ if (assistantText) {
699
+ parts.push({ type: "text", text: assistantText });
700
+ }
701
+
702
+ for (const tool of buffer.toolCalls) {
703
+ parts.push({ type: "tool", ...tool });
704
+ }
705
+
706
+ if (parts.length > 0) {
707
+ this.eventStore.emit({
708
+ type: "turn",
709
+ source: { agent_id: agentId },
710
+ payload: {
711
+ action: "recorded",
712
+ turn_id: `turn_asst_${now}_${Math.random().toString(36).slice(2, 8)}`,
713
+ conversation_id: acpSessionId,
714
+ participant: agentId,
715
+ timestamp: now + 1,
716
+ content_type: "assistant_response",
717
+ content: { parts },
718
+ source_type: "explicit",
719
+ },
720
+ });
721
+ }
722
+ } catch (error) {
723
+ console.warn(
724
+ `[ACP-over-MAP] Failed to record turns for session ${acpSessionId}:`,
725
+ error instanceof Error ? error.message : String(error),
726
+ );
727
+ }
728
+ }
729
+
546
730
  // ─────────────────────────────────────────────────────────────────
547
731
  // Session Info
548
732
  // ─────────────────────────────────────────────────────────────────