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.
- package/dist/agent/agent-manager.d.ts.map +1 -1
- package/dist/agent/agent-manager.js +23 -5
- package/dist/agent/agent-manager.js.map +1 -1
- package/dist/map/adapter/acp-over-map.d.ts +10 -0
- package/dist/map/adapter/acp-over-map.d.ts.map +1 -1
- package/dist/map/adapter/acp-over-map.js +157 -5
- package/dist/map/adapter/acp-over-map.js.map +1 -1
- package/dist/store/event-store.d.ts.map +1 -1
- package/dist/store/event-store.js +92 -53
- package/dist/store/event-store.js.map +1 -1
- package/dist/store/instance.d.ts +0 -2
- package/dist/store/instance.d.ts.map +1 -1
- package/dist/store/instance.js +1 -24
- package/dist/store/instance.js.map +1 -1
- package/package.json +3 -3
- package/references/acp-factory-ref/package-lock.json +2 -2
- package/references/acp-factory-ref/package.json +2 -2
- package/references/claude-code-acp/package-lock.json +2 -2
- package/references/claude-code-acp/package.json +1 -1
- package/references/claude-code-acp/src/acp-agent.ts +3 -6
- package/src/agent/__tests__/agent-manager.test.ts +4 -6
- package/src/agent/agent-manager.ts +24 -5
- package/src/map/adapter/__tests__/acp-over-map-history.test.ts +664 -0
- package/src/map/adapter/__tests__/acp-over-map-persistence.e2e.test.ts +440 -0
- package/src/map/adapter/acp-over-map.ts +189 -5
- package/src/store/__tests__/event-store.test.ts +4 -12
- package/src/store/__tests__/instance.test.ts +5 -7
- package/src/store/event-store.ts +115 -57
- package/src/store/instance.ts +1 -29
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
// ─────────────────────────────────────────────────────────────────
|