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.
- package/dist/acp/macro-agent.d.ts +2 -0
- package/dist/acp/macro-agent.d.ts.map +1 -1
- package/dist/acp/macro-agent.js +52 -20
- package/dist/acp/macro-agent.js.map +1 -1
- 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 +16 -0
- package/dist/map/adapter/acp-over-map.d.ts.map +1 -1
- package/dist/map/adapter/acp-over-map.js +242 -24
- package/dist/map/adapter/acp-over-map.js.map +1 -1
- package/dist/map/adapter/map-adapter.d.ts +1 -0
- package/dist/map/adapter/map-adapter.d.ts.map +1 -1
- package/dist/map/adapter/map-adapter.js +57 -8
- package/dist/map/adapter/map-adapter.js.map +1 -1
- package/dist/store/event-store.d.ts +5 -0
- package/dist/store/event-store.d.ts.map +1 -1
- package/dist/store/event-store.js +126 -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/dist/store/types/agents.d.ts +5 -0
- package/dist/store/types/agents.d.ts.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/acp/__tests__/history.test.ts +8 -4
- package/src/acp/macro-agent.ts +60 -26
- 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-cancel.test.ts +802 -0
- package/src/map/adapter/__tests__/acp-over-map-history.test.ts +1123 -0
- package/src/map/adapter/__tests__/acp-over-map-persistence.e2e.test.ts +440 -0
- package/src/map/adapter/acp-over-map.ts +282 -25
- package/src/map/adapter/map-adapter.ts +79 -9
- package/src/store/__tests__/event-store.test.ts +44 -12
- package/src/store/__tests__/instance.test.ts +5 -7
- package/src/store/event-store.ts +157 -57
- package/src/store/instance.ts +1 -29
- package/src/store/types/agents.ts +1 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP-over-MAP History Persistence Tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies that prompts sent through the ACP-over-MAP handler
|
|
5
|
+
* correctly record conversation turns in the EventStore, and that
|
|
6
|
+
* _macro/getHistory returns them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
10
|
+
import { ACPOverMAPHandler } from "../acp-over-map.js";
|
|
11
|
+
import type { ACPEnvelope } from "../acp-over-map.js";
|
|
12
|
+
import { createEventStore, type EventStore } from "../../../store/event-store.js";
|
|
13
|
+
import type { AgentManager } from "../../../agent/agent-manager.js";
|
|
14
|
+
import type { TaskManager } from "../../../task/task-manager.js";
|
|
15
|
+
import type { Agent, Task } from "../../../store/types/index.js";
|
|
16
|
+
import type { AgentId } from "../../../store/types/index.js";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────
|
|
19
|
+
// Helpers
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function createMockAgent(overrides: Partial<Agent> = {}): Agent {
|
|
23
|
+
return {
|
|
24
|
+
id: "agent-1" as AgentId,
|
|
25
|
+
session_id: "session-1",
|
|
26
|
+
state: "running",
|
|
27
|
+
task: "Test task",
|
|
28
|
+
task_id: "task-1",
|
|
29
|
+
parent: null,
|
|
30
|
+
lineage: [],
|
|
31
|
+
config: {},
|
|
32
|
+
cwd: "/test/cwd",
|
|
33
|
+
plan: [],
|
|
34
|
+
created_at: Date.now(),
|
|
35
|
+
started_at: Date.now(),
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createMockTask(overrides: Partial<Task> = {}): Task {
|
|
41
|
+
return {
|
|
42
|
+
id: "task-1",
|
|
43
|
+
description: "Test task",
|
|
44
|
+
status: "in_progress",
|
|
45
|
+
created_by: "agent-1",
|
|
46
|
+
created_at: Date.now(),
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createMockAgentManager(
|
|
52
|
+
promptUpdates: unknown[] = [],
|
|
53
|
+
): AgentManager {
|
|
54
|
+
const mockAgent = createMockAgent();
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
spawn: vi.fn().mockResolvedValue({
|
|
58
|
+
id: "agent-new",
|
|
59
|
+
session_id: "session-new",
|
|
60
|
+
agent: createMockAgent({ id: "agent-new" as AgentId, session_id: "session-new" }),
|
|
61
|
+
session: {},
|
|
62
|
+
}),
|
|
63
|
+
get: vi.fn().mockReturnValue(mockAgent),
|
|
64
|
+
list: vi.fn().mockReturnValue([mockAgent]),
|
|
65
|
+
listHeadManagers: vi.fn().mockReturnValue([mockAgent]),
|
|
66
|
+
getChildren: vi.fn().mockReturnValue([]),
|
|
67
|
+
getHierarchy: vi.fn().mockReturnValue({
|
|
68
|
+
root: { agent: mockAgent, children: [] },
|
|
69
|
+
depth: 1,
|
|
70
|
+
totalAgents: 1,
|
|
71
|
+
}),
|
|
72
|
+
getOrCreateHeadManager: vi.fn().mockResolvedValue({
|
|
73
|
+
id: "agent-1",
|
|
74
|
+
session_id: "session-1",
|
|
75
|
+
agent: mockAgent,
|
|
76
|
+
session: {},
|
|
77
|
+
}),
|
|
78
|
+
hasActiveSession: vi.fn().mockReturnValue(true),
|
|
79
|
+
resume: vi.fn().mockResolvedValue({
|
|
80
|
+
id: "agent-1",
|
|
81
|
+
session_id: "session-1",
|
|
82
|
+
agent: mockAgent,
|
|
83
|
+
session: {},
|
|
84
|
+
}),
|
|
85
|
+
terminate: vi.fn().mockResolvedValue(undefined),
|
|
86
|
+
prompt: vi.fn().mockReturnValue({
|
|
87
|
+
[Symbol.asyncIterator]: async function* () {
|
|
88
|
+
for (const update of promptUpdates) {
|
|
89
|
+
yield update;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
getSession: vi.fn().mockReturnValue(null),
|
|
94
|
+
onLifecycleEvent: vi.fn().mockReturnValue(() => {}),
|
|
95
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
96
|
+
respondToPermission: vi.fn().mockReturnValue(true),
|
|
97
|
+
cancelPermission: vi.fn().mockReturnValue(true),
|
|
98
|
+
} as unknown as AgentManager;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createMockTaskManager(): TaskManager {
|
|
102
|
+
return {
|
|
103
|
+
get: vi.fn().mockReturnValue(createMockTask()),
|
|
104
|
+
list: vi.fn().mockReturnValue([createMockTask()]),
|
|
105
|
+
create: vi.fn().mockReturnValue(createMockTask()),
|
|
106
|
+
} as unknown as TaskManager;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Build an ACP envelope for processRequest */
|
|
110
|
+
function envelope(
|
|
111
|
+
streamId: string,
|
|
112
|
+
method: string,
|
|
113
|
+
params?: unknown,
|
|
114
|
+
sessionId?: string,
|
|
115
|
+
): ACPEnvelope {
|
|
116
|
+
return {
|
|
117
|
+
acp: {
|
|
118
|
+
jsonrpc: "2.0",
|
|
119
|
+
id: `${streamId}-${method}-${Date.now()}`,
|
|
120
|
+
method,
|
|
121
|
+
params,
|
|
122
|
+
},
|
|
123
|
+
acpContext: {
|
|
124
|
+
streamId,
|
|
125
|
+
sessionId,
|
|
126
|
+
direction: "client-to-agent",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─────────────────────────────────────────────────────────────────
|
|
132
|
+
// Tests
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe("ACP-over-MAP history persistence", () => {
|
|
136
|
+
let eventStore: EventStore;
|
|
137
|
+
let handler: ACPOverMAPHandler;
|
|
138
|
+
|
|
139
|
+
afterEach(async () => {
|
|
140
|
+
await eventStore.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function setup(promptUpdates: unknown[] = []) {
|
|
144
|
+
eventStore = await createEventStore({ inMemory: true });
|
|
145
|
+
const agentManager = createMockAgentManager(promptUpdates);
|
|
146
|
+
const taskManager = createMockTaskManager();
|
|
147
|
+
|
|
148
|
+
handler = new ACPOverMAPHandler({
|
|
149
|
+
agentManager,
|
|
150
|
+
eventStore,
|
|
151
|
+
taskManager,
|
|
152
|
+
defaultCwd: "/test/cwd",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { agentManager, taskManager };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Register an agent in the EventStore so loadSession can resolve it */
|
|
159
|
+
function registerAgent(agentId: string, sessionId: string): void {
|
|
160
|
+
eventStore.emit({
|
|
161
|
+
type: "spawn",
|
|
162
|
+
source: { agent_id: agentId },
|
|
163
|
+
payload: {
|
|
164
|
+
agent_id: agentId,
|
|
165
|
+
session_id: sessionId,
|
|
166
|
+
task: "Test task",
|
|
167
|
+
task_id: "task-1",
|
|
168
|
+
cwd: "/test/cwd",
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
// Mark as running
|
|
172
|
+
eventStore.emit({
|
|
173
|
+
type: "lifecycle",
|
|
174
|
+
source: { agent_id: agentId },
|
|
175
|
+
payload: {
|
|
176
|
+
agent_id: agentId,
|
|
177
|
+
action: "started",
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Initialize a stream and create a session, returning the sessionId */
|
|
183
|
+
async function initAndCreateSession(
|
|
184
|
+
streamId: string,
|
|
185
|
+
targetAgentId: AgentId = "agent-1" as AgentId,
|
|
186
|
+
): Promise<string> {
|
|
187
|
+
// Initialize
|
|
188
|
+
await handler.processRequest(
|
|
189
|
+
targetAgentId,
|
|
190
|
+
envelope(streamId, "initialize", {
|
|
191
|
+
protocolVersion: 1,
|
|
192
|
+
capabilities: {},
|
|
193
|
+
clientInfo: { name: "test", version: "1.0" },
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Create session
|
|
198
|
+
const sessionResult = await handler.processRequest(
|
|
199
|
+
targetAgentId,
|
|
200
|
+
envelope(streamId, "session/new", {
|
|
201
|
+
cwd: "/test",
|
|
202
|
+
mcpServers: [],
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const sessionId = (sessionResult.acp.result as { sessionId?: string })?.sessionId;
|
|
207
|
+
if (!sessionId) throw new Error("session/new did not return sessionId");
|
|
208
|
+
|
|
209
|
+
// Register the agent in EventStore so loadSession can resolve it later
|
|
210
|
+
registerAgent(targetAgentId, sessionId);
|
|
211
|
+
|
|
212
|
+
return sessionId;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("should record user and assistant text turns after prompt via ACP-over-MAP", async () => {
|
|
216
|
+
await setup([
|
|
217
|
+
{
|
|
218
|
+
sessionUpdate: "agent_message_chunk",
|
|
219
|
+
content: { type: "text", text: "Hello " },
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
sessionUpdate: "agent_message_chunk",
|
|
223
|
+
content: { type: "text", text: "world!" },
|
|
224
|
+
},
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const streamId = "test-stream-1";
|
|
228
|
+
const agentId = "agent-1" as AgentId;
|
|
229
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
230
|
+
|
|
231
|
+
// Send prompt
|
|
232
|
+
await handler.processRequest(
|
|
233
|
+
agentId,
|
|
234
|
+
envelope(streamId, "session/prompt", {
|
|
235
|
+
prompt: [{ type: "text", text: "Say hello" }],
|
|
236
|
+
}, sessionId),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Get history via extension
|
|
240
|
+
const historyResult = await handler.processRequest(
|
|
241
|
+
agentId,
|
|
242
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
246
|
+
|
|
247
|
+
expect(turns).toHaveLength(2);
|
|
248
|
+
|
|
249
|
+
// User turn
|
|
250
|
+
expect(turns[0].role).toBe("user");
|
|
251
|
+
expect(turns[0].content).toBe("Say hello");
|
|
252
|
+
|
|
253
|
+
// Assistant turn — accumulated text chunks
|
|
254
|
+
expect(turns[1].role).toBe("assistant");
|
|
255
|
+
const content = turns[1].content as { parts: { type: string; text?: string }[] };
|
|
256
|
+
expect(content.parts[0].type).toBe("text");
|
|
257
|
+
expect(content.parts[0].text).toBe("Hello world!");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("should record tool calls in assistant turns", async () => {
|
|
261
|
+
await setup([
|
|
262
|
+
{
|
|
263
|
+
sessionUpdate: "agent_message_chunk",
|
|
264
|
+
content: { type: "text", text: "Let me check." },
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
sessionUpdate: "tool_call",
|
|
268
|
+
toolCallId: "tc-1",
|
|
269
|
+
title: "Read file",
|
|
270
|
+
status: "completed",
|
|
271
|
+
rawInput: { path: "/test.txt" },
|
|
272
|
+
rawOutput: "file contents",
|
|
273
|
+
},
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const streamId = "test-stream-2";
|
|
277
|
+
const agentId = "agent-1" as AgentId;
|
|
278
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
279
|
+
|
|
280
|
+
await handler.processRequest(
|
|
281
|
+
agentId,
|
|
282
|
+
envelope(streamId, "session/prompt", {
|
|
283
|
+
prompt: [{ type: "text", text: "Read test.txt" }],
|
|
284
|
+
}, sessionId),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const historyResult = await handler.processRequest(
|
|
288
|
+
agentId,
|
|
289
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
293
|
+
|
|
294
|
+
expect(turns).toHaveLength(2);
|
|
295
|
+
|
|
296
|
+
const assistantContent = turns[1].content as {
|
|
297
|
+
parts: { type: string; text?: string; toolCallId?: string; title?: string; output?: unknown }[];
|
|
298
|
+
};
|
|
299
|
+
expect(assistantContent.parts).toHaveLength(2);
|
|
300
|
+
expect(assistantContent.parts[0]).toEqual({ type: "text", text: "Let me check." });
|
|
301
|
+
expect(assistantContent.parts[1]).toMatchObject({
|
|
302
|
+
type: "tool",
|
|
303
|
+
toolCallId: "tc-1",
|
|
304
|
+
title: "Read file",
|
|
305
|
+
status: "completed",
|
|
306
|
+
output: "file contents",
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should extract tool output from rawOutput ContentBlock array", async () => {
|
|
311
|
+
await setup([
|
|
312
|
+
{
|
|
313
|
+
sessionUpdate: "tool_call",
|
|
314
|
+
toolCallId: "tc-array",
|
|
315
|
+
title: "Read file",
|
|
316
|
+
status: "completed",
|
|
317
|
+
rawInput: { path: "/test.txt" },
|
|
318
|
+
rawOutput: [
|
|
319
|
+
{ type: "text", text: "line 1" },
|
|
320
|
+
{ type: "text", text: "line 2" },
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const streamId = "test-stream-array-output";
|
|
326
|
+
const agentId = "agent-1" as AgentId;
|
|
327
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
328
|
+
|
|
329
|
+
await handler.processRequest(
|
|
330
|
+
agentId,
|
|
331
|
+
envelope(streamId, "session/prompt", {
|
|
332
|
+
prompt: [{ type: "text", text: "Read it" }],
|
|
333
|
+
}, sessionId),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const historyResult = await handler.processRequest(
|
|
337
|
+
agentId,
|
|
338
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
342
|
+
const assistantContent = turns[1].content as {
|
|
343
|
+
parts: { type: string; output?: string }[];
|
|
344
|
+
};
|
|
345
|
+
const toolPart = assistantContent.parts.find((p) => p.type === "tool");
|
|
346
|
+
expect(toolPart?.output).toBe("line 1\nline 2");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("should merge title from initial tool_call into tool_call_update", async () => {
|
|
350
|
+
// Simulates WebSearch/WebFetch: initial tool_call has title, but
|
|
351
|
+
// tool_call_update (completed) does not include title.
|
|
352
|
+
await setup([
|
|
353
|
+
{
|
|
354
|
+
sessionUpdate: "tool_call",
|
|
355
|
+
toolCallId: "tc-ws",
|
|
356
|
+
title: "Search query here",
|
|
357
|
+
status: "pending",
|
|
358
|
+
rawInput: { query: "test" },
|
|
359
|
+
_meta: { claudeCode: { toolName: "WebSearch" } },
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
sessionUpdate: "tool_call_update",
|
|
363
|
+
toolCallId: "tc-ws",
|
|
364
|
+
status: "completed",
|
|
365
|
+
rawOutput: "search results",
|
|
366
|
+
// No title, no rawInput, no _meta — should use cached values
|
|
367
|
+
},
|
|
368
|
+
]);
|
|
369
|
+
|
|
370
|
+
const streamId = "test-stream-merge-title";
|
|
371
|
+
const agentId = "agent-1" as AgentId;
|
|
372
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
373
|
+
|
|
374
|
+
await handler.processRequest(
|
|
375
|
+
agentId,
|
|
376
|
+
envelope(streamId, "session/prompt", {
|
|
377
|
+
prompt: [{ type: "text", text: "Search for test" }],
|
|
378
|
+
}, sessionId),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const historyResult = await handler.processRequest(
|
|
382
|
+
agentId,
|
|
383
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
387
|
+
const assistantContent = turns[1].content as {
|
|
388
|
+
parts: { type: string; title?: string; name?: string; input?: unknown; output?: string }[];
|
|
389
|
+
};
|
|
390
|
+
const toolPart = assistantContent.parts.find((p) => p.type === "tool");
|
|
391
|
+
expect(toolPart?.title).toBe("Search query here");
|
|
392
|
+
expect(toolPart?.name).toBe("WebSearch");
|
|
393
|
+
expect(toolPart?.input).toEqual({ query: "test" });
|
|
394
|
+
expect(toolPart?.output).toBe("search results");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("should accumulate history across multiple prompts", async () => {
|
|
398
|
+
const { agentManager } = await setup([
|
|
399
|
+
{
|
|
400
|
+
sessionUpdate: "agent_message_chunk",
|
|
401
|
+
content: { type: "text", text: "Hello" },
|
|
402
|
+
},
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
const streamId = "test-stream-3";
|
|
406
|
+
const agentId = "agent-1" as AgentId;
|
|
407
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
408
|
+
|
|
409
|
+
// First prompt
|
|
410
|
+
await handler.processRequest(
|
|
411
|
+
agentId,
|
|
412
|
+
envelope(streamId, "session/prompt", {
|
|
413
|
+
prompt: [{ type: "text", text: "Hi" }],
|
|
414
|
+
}, sessionId),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Update mock for second prompt
|
|
418
|
+
(agentManager.prompt as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
419
|
+
[Symbol.asyncIterator]: async function* () {
|
|
420
|
+
yield {
|
|
421
|
+
sessionUpdate: "agent_message_chunk",
|
|
422
|
+
content: { type: "text", text: "Goodbye" },
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
} as any);
|
|
426
|
+
|
|
427
|
+
// Second prompt
|
|
428
|
+
await handler.processRequest(
|
|
429
|
+
agentId,
|
|
430
|
+
envelope(streamId, "session/prompt", {
|
|
431
|
+
prompt: [{ type: "text", text: "Bye" }],
|
|
432
|
+
}, sessionId),
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
const historyResult = await handler.processRequest(
|
|
436
|
+
agentId,
|
|
437
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
441
|
+
|
|
442
|
+
// 2 prompts × 2 turns = 4 turns total
|
|
443
|
+
expect(turns).toHaveLength(4);
|
|
444
|
+
expect(turns[0].role).toBe("user");
|
|
445
|
+
expect(turns[0].content).toBe("Hi");
|
|
446
|
+
expect(turns[1].role).toBe("assistant");
|
|
447
|
+
expect(turns[2].role).toBe("user");
|
|
448
|
+
expect(turns[2].content).toBe("Bye");
|
|
449
|
+
expect(turns[3].role).toBe("assistant");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("should return empty turns for session with no history", async () => {
|
|
453
|
+
await setup();
|
|
454
|
+
|
|
455
|
+
const streamId = "test-stream-4";
|
|
456
|
+
const agentId = "agent-1" as AgentId;
|
|
457
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
458
|
+
|
|
459
|
+
const historyResult = await handler.processRequest(
|
|
460
|
+
agentId,
|
|
461
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const turns = (historyResult.acp.result as { turns: unknown[] }).turns;
|
|
465
|
+
expect(turns).toEqual([]);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("should only record completed tool calls, not running ones", async () => {
|
|
469
|
+
await setup([
|
|
470
|
+
{
|
|
471
|
+
sessionUpdate: "tool_call",
|
|
472
|
+
toolCallId: "tc-running",
|
|
473
|
+
title: "Running tool",
|
|
474
|
+
status: "running",
|
|
475
|
+
rawInput: {},
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
sessionUpdate: "tool_call",
|
|
479
|
+
toolCallId: "tc-done",
|
|
480
|
+
title: "Done tool",
|
|
481
|
+
status: "completed",
|
|
482
|
+
rawInput: { x: 1 },
|
|
483
|
+
rawOutput: "result",
|
|
484
|
+
},
|
|
485
|
+
]);
|
|
486
|
+
|
|
487
|
+
const streamId = "test-stream-5";
|
|
488
|
+
const agentId = "agent-1" as AgentId;
|
|
489
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
490
|
+
|
|
491
|
+
await handler.processRequest(
|
|
492
|
+
agentId,
|
|
493
|
+
envelope(streamId, "session/prompt", {
|
|
494
|
+
prompt: [{ type: "text", text: "Run tools" }],
|
|
495
|
+
}, sessionId),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const historyResult = await handler.processRequest(
|
|
499
|
+
agentId,
|
|
500
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
504
|
+
const assistantContent = turns[1].content as {
|
|
505
|
+
parts: { type: string; toolCallId?: string }[];
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const toolParts = assistantContent.parts.filter((p) => p.type === "tool");
|
|
509
|
+
expect(toolParts).toHaveLength(1);
|
|
510
|
+
expect(toolParts[0].toolCallId).toBe("tc-done");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("should restore history after reconnection via _resolve_ pattern (full TUI flow)", async () => {
|
|
514
|
+
// This test simulates the full TUI reconnection scenario:
|
|
515
|
+
// 1. Stream 1: Create session, send prompt (records turns)
|
|
516
|
+
// 2. Stream 2: Reconnect with _resolve_, loadSession returns sessionId, getHistory returns turns
|
|
517
|
+
|
|
518
|
+
const { agentManager } = await setup([
|
|
519
|
+
{
|
|
520
|
+
sessionUpdate: "agent_message_chunk",
|
|
521
|
+
content: { type: "text", text: "Hello from agent!" },
|
|
522
|
+
},
|
|
523
|
+
]);
|
|
524
|
+
|
|
525
|
+
const agentId = "agent-1" as AgentId;
|
|
526
|
+
|
|
527
|
+
// ── Stream 1: Initial session ──
|
|
528
|
+
const stream1 = "stream-reconnect-1";
|
|
529
|
+
const sessionId = await initAndCreateSession(stream1, agentId);
|
|
530
|
+
|
|
531
|
+
// Send a prompt (records turns in EventStore)
|
|
532
|
+
await handler.processRequest(
|
|
533
|
+
agentId,
|
|
534
|
+
envelope(stream1, "session/prompt", {
|
|
535
|
+
prompt: [{ type: "text", text: "Hello agent" }],
|
|
536
|
+
}, sessionId),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// Verify turns exist
|
|
540
|
+
const check = await handler.processRequest(
|
|
541
|
+
agentId,
|
|
542
|
+
envelope(stream1, "_macro/getHistory", { sessionId }),
|
|
543
|
+
);
|
|
544
|
+
expect((check.acp.result as { turns: unknown[] }).turns).toHaveLength(2);
|
|
545
|
+
|
|
546
|
+
// ── Stream 2: Simulates TUI restart / reconnection ──
|
|
547
|
+
const stream2 = "stream-reconnect-2";
|
|
548
|
+
|
|
549
|
+
// Initialize the new stream
|
|
550
|
+
await handler.processRequest(
|
|
551
|
+
agentId,
|
|
552
|
+
envelope(stream2, "initialize", {
|
|
553
|
+
protocolVersion: 1,
|
|
554
|
+
capabilities: {},
|
|
555
|
+
clientInfo: { name: "test-reconnect", version: "1.0" },
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
// loadSession with _resolve_ pattern (TUI sends agentId in _meta)
|
|
560
|
+
// The mock agent has session_id: "session-1", so loadSession should resolve it
|
|
561
|
+
const loadResult = await handler.processRequest(
|
|
562
|
+
agentId,
|
|
563
|
+
envelope(stream2, "session/load", {
|
|
564
|
+
sessionId: "_resolve_",
|
|
565
|
+
cwd: "/test",
|
|
566
|
+
mcpServers: [],
|
|
567
|
+
_meta: { agentId },
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// The loadResult MUST include the resolved sessionId
|
|
572
|
+
const resolvedSessionId = (loadResult.acp.result as { sessionId?: string })?.sessionId;
|
|
573
|
+
expect(resolvedSessionId).toBeDefined();
|
|
574
|
+
expect(resolvedSessionId).toBe(sessionId);
|
|
575
|
+
expect(resolvedSessionId).not.toBe("_resolve_");
|
|
576
|
+
|
|
577
|
+
// Now the TUI uses the resolved sessionId to call getHistory
|
|
578
|
+
const historyResult = await handler.processRequest(
|
|
579
|
+
agentId,
|
|
580
|
+
envelope(stream2, "_macro/getHistory", { sessionId: resolvedSessionId }),
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
584
|
+
expect(turns).toHaveLength(2);
|
|
585
|
+
expect(turns[0].role).toBe("user");
|
|
586
|
+
expect(turns[0].content).toBe("Hello agent");
|
|
587
|
+
expect(turns[1].role).toBe("assistant");
|
|
588
|
+
const content = turns[1].content as { parts: { type: string; text: string }[] };
|
|
589
|
+
expect(content.parts[0].text).toBe("Hello from agent!");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("should return sessionId from loadSession on all code paths", async () => {
|
|
593
|
+
// Verify that loadSession always returns { sessionId } so the TUI
|
|
594
|
+
// never falls back to using agentId for history queries.
|
|
595
|
+
await setup();
|
|
596
|
+
|
|
597
|
+
const agentId = "agent-1" as AgentId;
|
|
598
|
+
const streamId = "stream-sessionid-test";
|
|
599
|
+
|
|
600
|
+
// Initialize
|
|
601
|
+
await handler.processRequest(
|
|
602
|
+
agentId,
|
|
603
|
+
envelope(streamId, "initialize", {
|
|
604
|
+
protocolVersion: 1,
|
|
605
|
+
capabilities: {},
|
|
606
|
+
clientInfo: { name: "test", version: "1.0" },
|
|
607
|
+
}),
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// Create a session first so the agent exists
|
|
611
|
+
const newResult = await handler.processRequest(
|
|
612
|
+
agentId,
|
|
613
|
+
envelope(streamId, "session/new", {
|
|
614
|
+
cwd: "/test",
|
|
615
|
+
mcpServers: [],
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
const sessionId = (newResult.acp.result as { sessionId?: string })?.sessionId;
|
|
619
|
+
expect(sessionId).toBeDefined();
|
|
620
|
+
|
|
621
|
+
// Register the agent in EventStore so loadSession can resolve it
|
|
622
|
+
registerAgent(agentId, sessionId!);
|
|
623
|
+
|
|
624
|
+
// Now loadSession on a new stream — should return the sessionId
|
|
625
|
+
const stream2 = "stream-sessionid-test-2";
|
|
626
|
+
await handler.processRequest(
|
|
627
|
+
agentId,
|
|
628
|
+
envelope(stream2, "initialize", {
|
|
629
|
+
protocolVersion: 1,
|
|
630
|
+
capabilities: {},
|
|
631
|
+
clientInfo: { name: "test", version: "1.0" },
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const loadResult = await handler.processRequest(
|
|
636
|
+
agentId,
|
|
637
|
+
envelope(stream2, "session/load", {
|
|
638
|
+
sessionId: "_resolve_",
|
|
639
|
+
cwd: "/test",
|
|
640
|
+
mcpServers: [],
|
|
641
|
+
_meta: { agentId },
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
const resolved = (loadResult.acp.result as { sessionId?: string })?.sessionId;
|
|
646
|
+
expect(resolved).toBe(sessionId);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("should stream notifications back via emitNotification callback", async () => {
|
|
650
|
+
await setup([
|
|
651
|
+
{
|
|
652
|
+
sessionUpdate: "agent_message_chunk",
|
|
653
|
+
content: { type: "text", text: "Hello" },
|
|
654
|
+
},
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
const streamId = "test-stream-6";
|
|
658
|
+
const agentId = "agent-1" as AgentId;
|
|
659
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
660
|
+
|
|
661
|
+
const notifications: ACPEnvelope[] = [];
|
|
662
|
+
const emitNotification = (n: ACPEnvelope) => notifications.push(n);
|
|
663
|
+
|
|
664
|
+
await handler.processRequest(
|
|
665
|
+
agentId,
|
|
666
|
+
envelope(streamId, "session/prompt", {
|
|
667
|
+
prompt: [{ type: "text", text: "Say hello" }],
|
|
668
|
+
}, sessionId),
|
|
669
|
+
emitNotification,
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
// Should have at least the session update notification
|
|
673
|
+
const sessionUpdates = notifications.filter(
|
|
674
|
+
(n) => n.acp.method === "session/update",
|
|
675
|
+
);
|
|
676
|
+
expect(sessionUpdates.length).toBeGreaterThan(0);
|
|
677
|
+
|
|
678
|
+
// AND history should still be recorded
|
|
679
|
+
const historyResult = await handler.processRequest(
|
|
680
|
+
agentId,
|
|
681
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
const turns = (historyResult.acp.result as { turns: unknown[] }).turns;
|
|
685
|
+
expect(turns).toHaveLength(2);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("should resolve history via agentId when sessionId is different (restart scenario)", async () => {
|
|
689
|
+
// This test simulates the scenario where:
|
|
690
|
+
// 1. Session is created, prompt is sent (history recorded under sessionId)
|
|
691
|
+
// 2. Server restarts, resume() fails, TUI creates a NEW session
|
|
692
|
+
// 3. TUI calls _macro/getHistory with the NEW sessionId + agentId
|
|
693
|
+
// 4. Server uses agentId to resolve the OLD session and return its history
|
|
694
|
+
|
|
695
|
+
await setup([
|
|
696
|
+
{
|
|
697
|
+
sessionUpdate: "agent_message_chunk",
|
|
698
|
+
content: { type: "text", text: "I remember you!" },
|
|
699
|
+
},
|
|
700
|
+
]);
|
|
701
|
+
|
|
702
|
+
const agentId = "agent-1" as AgentId;
|
|
703
|
+
const streamId = "stream-agentid-lookup";
|
|
704
|
+
const originalSessionId = await initAndCreateSession(streamId, agentId);
|
|
705
|
+
|
|
706
|
+
// Send a prompt (records turns under originalSessionId)
|
|
707
|
+
await handler.processRequest(
|
|
708
|
+
agentId,
|
|
709
|
+
envelope(streamId, "session/prompt", {
|
|
710
|
+
prompt: [{ type: "text", text: "Remember me" }],
|
|
711
|
+
}, originalSessionId),
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
// Verify turns exist under original session
|
|
715
|
+
const check = await handler.processRequest(
|
|
716
|
+
agentId,
|
|
717
|
+
envelope(streamId, "_macro/getHistory", { sessionId: originalSessionId }),
|
|
718
|
+
);
|
|
719
|
+
expect((check.acp.result as { turns: unknown[] }).turns).toHaveLength(2);
|
|
720
|
+
|
|
721
|
+
// Simulate calling getHistory with a DIFFERENT sessionId + agentId
|
|
722
|
+
// (as happens when resume fails and TUI creates a new session).
|
|
723
|
+
// agentId takes priority — resolves to the original session with history.
|
|
724
|
+
const newSessionId = "completely-different-session-id";
|
|
725
|
+
const historyResult = await handler.processRequest(
|
|
726
|
+
agentId,
|
|
727
|
+
envelope(streamId, "_macro/getHistory", {
|
|
728
|
+
sessionId: newSessionId,
|
|
729
|
+
agentId,
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// agentId resolves to the OLD session, so we should get history
|
|
734
|
+
const turns = (historyResult.acp.result as { turns: { role: string; content: unknown }[] }).turns;
|
|
735
|
+
expect(turns).toHaveLength(2);
|
|
736
|
+
expect(turns[0].role).toBe("user");
|
|
737
|
+
expect(turns[0].content).toBe("Remember me");
|
|
738
|
+
expect(turns[1].role).toBe("assistant");
|
|
739
|
+
const content = turns[1].content as { parts: { text: string }[] };
|
|
740
|
+
expect(content.parts[0].text).toBe("I remember you!");
|
|
741
|
+
|
|
742
|
+
// Without agentId, only sessionId is used — new session has no turns
|
|
743
|
+
const historyNoAgent = await handler.processRequest(
|
|
744
|
+
agentId,
|
|
745
|
+
envelope(streamId, "_macro/getHistory", {
|
|
746
|
+
sessionId: newSessionId,
|
|
747
|
+
}),
|
|
748
|
+
);
|
|
749
|
+
const turnsNoAgent = (historyNoAgent.acp.result as { turns: unknown[] }).turns;
|
|
750
|
+
expect(turnsNoAgent).toHaveLength(0);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("should include agent cwd in getHistory response", async () => {
|
|
754
|
+
await setup([
|
|
755
|
+
{
|
|
756
|
+
sessionUpdate: "agent_message_chunk",
|
|
757
|
+
content: { type: "text", text: "Working in dir" },
|
|
758
|
+
},
|
|
759
|
+
]);
|
|
760
|
+
|
|
761
|
+
const agentId = "agent-1" as AgentId;
|
|
762
|
+
const streamId = "test-stream-cwd";
|
|
763
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
764
|
+
|
|
765
|
+
await handler.processRequest(
|
|
766
|
+
agentId,
|
|
767
|
+
envelope(streamId, "session/prompt", {
|
|
768
|
+
prompt: [{ type: "text", text: "Where are you?" }],
|
|
769
|
+
}, sessionId),
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
const historyResult = await handler.processRequest(
|
|
773
|
+
agentId,
|
|
774
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
const result = historyResult.acp.result as { turns: unknown[]; cwd: string | null };
|
|
778
|
+
expect(result.cwd).toBe("/test/cwd");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("should return null cwd when agentId is not provided", async () => {
|
|
782
|
+
await setup([
|
|
783
|
+
{
|
|
784
|
+
sessionUpdate: "agent_message_chunk",
|
|
785
|
+
content: { type: "text", text: "Hello" },
|
|
786
|
+
},
|
|
787
|
+
]);
|
|
788
|
+
|
|
789
|
+
const agentId = "agent-1" as AgentId;
|
|
790
|
+
const streamId = "test-stream-cwd-null";
|
|
791
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
792
|
+
|
|
793
|
+
await handler.processRequest(
|
|
794
|
+
agentId,
|
|
795
|
+
envelope(streamId, "session/prompt", {
|
|
796
|
+
prompt: [{ type: "text", text: "Hi" }],
|
|
797
|
+
}, sessionId),
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
// Query without agentId — cwd should be null
|
|
801
|
+
const historyResult = await handler.processRequest(
|
|
802
|
+
agentId,
|
|
803
|
+
envelope(streamId, "_macro/getHistory", { sessionId }),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const result = historyResult.acp.result as { turns: unknown[]; cwd: string | null };
|
|
807
|
+
expect(result.cwd).toBeNull();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("should capture plan entries from streaming and include in getHistory", async () => {
|
|
811
|
+
await setup([
|
|
812
|
+
{
|
|
813
|
+
sessionUpdate: "agent_message_chunk",
|
|
814
|
+
content: { type: "text", text: "Planning..." },
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
sessionUpdate: "plan",
|
|
818
|
+
entries: [
|
|
819
|
+
{ content: "Analyze codebase", priority: "high", status: "in_progress" },
|
|
820
|
+
{ content: "Write tests", priority: "medium", status: "pending" },
|
|
821
|
+
{ content: "Deploy", priority: "low", status: "pending" },
|
|
822
|
+
],
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
sessionUpdate: "agent_message_chunk",
|
|
826
|
+
content: { type: "text", text: " Done." },
|
|
827
|
+
},
|
|
828
|
+
]);
|
|
829
|
+
|
|
830
|
+
const agentId = "agent-1" as AgentId;
|
|
831
|
+
const streamId = "test-stream-plan";
|
|
832
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
833
|
+
|
|
834
|
+
await handler.processRequest(
|
|
835
|
+
agentId,
|
|
836
|
+
envelope(streamId, "session/prompt", {
|
|
837
|
+
prompt: [{ type: "text", text: "Make a plan" }],
|
|
838
|
+
}, sessionId),
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
const historyResult = await handler.processRequest(
|
|
842
|
+
agentId,
|
|
843
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const result = historyResult.acp.result as {
|
|
847
|
+
turns: unknown[];
|
|
848
|
+
plan: Array<{ content: string; priority: string; status: string }>;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
expect(result.plan).toHaveLength(3);
|
|
852
|
+
expect(result.plan[0]).toEqual({
|
|
853
|
+
content: "Analyze codebase",
|
|
854
|
+
priority: "high",
|
|
855
|
+
status: "in_progress",
|
|
856
|
+
});
|
|
857
|
+
expect(result.plan[1]).toEqual({
|
|
858
|
+
content: "Write tests",
|
|
859
|
+
priority: "medium",
|
|
860
|
+
status: "pending",
|
|
861
|
+
});
|
|
862
|
+
expect(result.plan[2]).toEqual({
|
|
863
|
+
content: "Deploy",
|
|
864
|
+
priority: "low",
|
|
865
|
+
status: "pending",
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("should return empty plan when no plan updates were received", async () => {
|
|
870
|
+
await setup([
|
|
871
|
+
{
|
|
872
|
+
sessionUpdate: "agent_message_chunk",
|
|
873
|
+
content: { type: "text", text: "No plan here" },
|
|
874
|
+
},
|
|
875
|
+
]);
|
|
876
|
+
|
|
877
|
+
const agentId = "agent-1" as AgentId;
|
|
878
|
+
const streamId = "test-stream-no-plan";
|
|
879
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
880
|
+
|
|
881
|
+
await handler.processRequest(
|
|
882
|
+
agentId,
|
|
883
|
+
envelope(streamId, "session/prompt", {
|
|
884
|
+
prompt: [{ type: "text", text: "Just chat" }],
|
|
885
|
+
}, sessionId),
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
const historyResult = await handler.processRequest(
|
|
889
|
+
agentId,
|
|
890
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const result = historyResult.acp.result as {
|
|
894
|
+
turns: unknown[];
|
|
895
|
+
plan: unknown[];
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
expect(result.plan).toEqual([]);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it("should use latest plan when multiple plan updates are received", async () => {
|
|
902
|
+
await setup([
|
|
903
|
+
{
|
|
904
|
+
sessionUpdate: "plan",
|
|
905
|
+
entries: [
|
|
906
|
+
{ content: "Step 1", priority: "high", status: "pending" },
|
|
907
|
+
],
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
sessionUpdate: "agent_message_chunk",
|
|
911
|
+
content: { type: "text", text: "Working..." },
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
sessionUpdate: "plan",
|
|
915
|
+
entries: [
|
|
916
|
+
{ content: "Step 1", priority: "high", status: "completed" },
|
|
917
|
+
{ content: "Step 2", priority: "medium", status: "in_progress" },
|
|
918
|
+
],
|
|
919
|
+
},
|
|
920
|
+
]);
|
|
921
|
+
|
|
922
|
+
const agentId = "agent-1" as AgentId;
|
|
923
|
+
const streamId = "test-stream-plan-latest";
|
|
924
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
925
|
+
|
|
926
|
+
await handler.processRequest(
|
|
927
|
+
agentId,
|
|
928
|
+
envelope(streamId, "session/prompt", {
|
|
929
|
+
prompt: [{ type: "text", text: "Work on it" }],
|
|
930
|
+
}, sessionId),
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
const historyResult = await handler.processRequest(
|
|
934
|
+
agentId,
|
|
935
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
const result = historyResult.acp.result as {
|
|
939
|
+
turns: unknown[];
|
|
940
|
+
plan: Array<{ content: string; priority: string; status: string }>;
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// Should have the LATEST plan (second update)
|
|
944
|
+
expect(result.plan).toHaveLength(2);
|
|
945
|
+
expect(result.plan[0].status).toBe("completed");
|
|
946
|
+
expect(result.plan[1].status).toBe("in_progress");
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it("should persist plan across multiple prompts (latest wins)", async () => {
|
|
950
|
+
const { agentManager } = await setup([
|
|
951
|
+
{
|
|
952
|
+
sessionUpdate: "plan",
|
|
953
|
+
entries: [
|
|
954
|
+
{ content: "Initial task", priority: "high", status: "in_progress" },
|
|
955
|
+
],
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
sessionUpdate: "agent_message_chunk",
|
|
959
|
+
content: { type: "text", text: "First response" },
|
|
960
|
+
},
|
|
961
|
+
]);
|
|
962
|
+
|
|
963
|
+
const agentId = "agent-1" as AgentId;
|
|
964
|
+
const streamId = "test-stream-plan-persist";
|
|
965
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
966
|
+
|
|
967
|
+
// First prompt — sets initial plan
|
|
968
|
+
await handler.processRequest(
|
|
969
|
+
agentId,
|
|
970
|
+
envelope(streamId, "session/prompt", {
|
|
971
|
+
prompt: [{ type: "text", text: "Start working" }],
|
|
972
|
+
}, sessionId),
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
// Verify plan from first prompt
|
|
976
|
+
let historyResult = await handler.processRequest(
|
|
977
|
+
agentId,
|
|
978
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
979
|
+
);
|
|
980
|
+
let result = historyResult.acp.result as {
|
|
981
|
+
turns: unknown[];
|
|
982
|
+
plan: Array<{ content: string; priority: string; status: string }>;
|
|
983
|
+
};
|
|
984
|
+
expect(result.plan).toHaveLength(1);
|
|
985
|
+
expect(result.plan[0].content).toBe("Initial task");
|
|
986
|
+
|
|
987
|
+
// Second prompt with updated plan
|
|
988
|
+
(agentManager.prompt as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
989
|
+
[Symbol.asyncIterator]: async function* () {
|
|
990
|
+
yield {
|
|
991
|
+
sessionUpdate: "plan",
|
|
992
|
+
entries: [
|
|
993
|
+
{ content: "Initial task", priority: "high", status: "completed" },
|
|
994
|
+
{ content: "New task", priority: "medium", status: "in_progress" },
|
|
995
|
+
],
|
|
996
|
+
};
|
|
997
|
+
yield {
|
|
998
|
+
sessionUpdate: "agent_message_chunk",
|
|
999
|
+
content: { type: "text", text: "Second response" },
|
|
1000
|
+
};
|
|
1001
|
+
},
|
|
1002
|
+
} as any);
|
|
1003
|
+
|
|
1004
|
+
await handler.processRequest(
|
|
1005
|
+
agentId,
|
|
1006
|
+
envelope(streamId, "session/prompt", {
|
|
1007
|
+
prompt: [{ type: "text", text: "Continue" }],
|
|
1008
|
+
}, sessionId),
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
// Plan should now reflect the second prompt's update
|
|
1012
|
+
historyResult = await handler.processRequest(
|
|
1013
|
+
agentId,
|
|
1014
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
1015
|
+
);
|
|
1016
|
+
result = historyResult.acp.result as {
|
|
1017
|
+
turns: unknown[];
|
|
1018
|
+
plan: Array<{ content: string; priority: string; status: string }>;
|
|
1019
|
+
};
|
|
1020
|
+
expect(result.plan).toHaveLength(2);
|
|
1021
|
+
expect(result.plan[0].status).toBe("completed");
|
|
1022
|
+
expect(result.plan[1].content).toBe("New task");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("should keep plan from earlier prompt if new prompt has no plan updates", async () => {
|
|
1026
|
+
const { agentManager } = await setup([
|
|
1027
|
+
{
|
|
1028
|
+
sessionUpdate: "plan",
|
|
1029
|
+
entries: [
|
|
1030
|
+
{ content: "Persistent task", priority: "high", status: "in_progress" },
|
|
1031
|
+
],
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
sessionUpdate: "agent_message_chunk",
|
|
1035
|
+
content: { type: "text", text: "First" },
|
|
1036
|
+
},
|
|
1037
|
+
]);
|
|
1038
|
+
|
|
1039
|
+
const agentId = "agent-1" as AgentId;
|
|
1040
|
+
const streamId = "test-stream-plan-keep";
|
|
1041
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
1042
|
+
|
|
1043
|
+
// First prompt — sets plan
|
|
1044
|
+
await handler.processRequest(
|
|
1045
|
+
agentId,
|
|
1046
|
+
envelope(streamId, "session/prompt", {
|
|
1047
|
+
prompt: [{ type: "text", text: "Plan it" }],
|
|
1048
|
+
}, sessionId),
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
// Second prompt with NO plan updates
|
|
1052
|
+
(agentManager.prompt as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
1053
|
+
[Symbol.asyncIterator]: async function* () {
|
|
1054
|
+
yield {
|
|
1055
|
+
sessionUpdate: "agent_message_chunk",
|
|
1056
|
+
content: { type: "text", text: "No plan this time" },
|
|
1057
|
+
};
|
|
1058
|
+
},
|
|
1059
|
+
} as any);
|
|
1060
|
+
|
|
1061
|
+
await handler.processRequest(
|
|
1062
|
+
agentId,
|
|
1063
|
+
envelope(streamId, "session/prompt", {
|
|
1064
|
+
prompt: [{ type: "text", text: "Just chat" }],
|
|
1065
|
+
}, sessionId),
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
// Plan should still be present from the first prompt
|
|
1069
|
+
const historyResult = await handler.processRequest(
|
|
1070
|
+
agentId,
|
|
1071
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
1072
|
+
);
|
|
1073
|
+
const result = historyResult.acp.result as {
|
|
1074
|
+
turns: unknown[];
|
|
1075
|
+
plan: Array<{ content: string; priority: string; status: string }>;
|
|
1076
|
+
};
|
|
1077
|
+
expect(result.plan).toHaveLength(1);
|
|
1078
|
+
expect(result.plan[0].content).toBe("Persistent task");
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it("should include both plan and cwd together in getHistory response", async () => {
|
|
1082
|
+
await setup([
|
|
1083
|
+
{
|
|
1084
|
+
sessionUpdate: "plan",
|
|
1085
|
+
entries: [
|
|
1086
|
+
{ content: "Do something", priority: "high", status: "pending" },
|
|
1087
|
+
],
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
sessionUpdate: "agent_message_chunk",
|
|
1091
|
+
content: { type: "text", text: "Got it" },
|
|
1092
|
+
},
|
|
1093
|
+
]);
|
|
1094
|
+
|
|
1095
|
+
const agentId = "agent-1" as AgentId;
|
|
1096
|
+
const streamId = "test-stream-plan-cwd";
|
|
1097
|
+
const sessionId = await initAndCreateSession(streamId, agentId);
|
|
1098
|
+
|
|
1099
|
+
await handler.processRequest(
|
|
1100
|
+
agentId,
|
|
1101
|
+
envelope(streamId, "session/prompt", {
|
|
1102
|
+
prompt: [{ type: "text", text: "Go" }],
|
|
1103
|
+
}, sessionId),
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
const historyResult = await handler.processRequest(
|
|
1107
|
+
agentId,
|
|
1108
|
+
envelope(streamId, "_macro/getHistory", { sessionId, agentId }),
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
const result = historyResult.acp.result as {
|
|
1112
|
+
turns: unknown[];
|
|
1113
|
+
plan: Array<{ content: string; priority: string; status: string }>;
|
|
1114
|
+
cwd: string | null;
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
// Both fields present
|
|
1118
|
+
expect(result.turns).toHaveLength(2);
|
|
1119
|
+
expect(result.plan).toHaveLength(1);
|
|
1120
|
+
expect(result.plan[0].content).toBe("Do something");
|
|
1121
|
+
expect(result.cwd).toBe("/test/cwd");
|
|
1122
|
+
});
|
|
1123
|
+
});
|