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
|
@@ -15,6 +15,22 @@ import type { AgentId } from "../../store/types/index.js";
|
|
|
15
15
|
import { SessionMapper } from "../../acp/session-mapper.js";
|
|
16
16
|
import type { ACPSessionId } from "../../acp/types.js";
|
|
17
17
|
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────
|
|
19
|
+
// Helpers
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Extract a plain-text output string from `rawOutput` (string | ContentBlock[] | undefined). */
|
|
23
|
+
function extractToolOutput(rawOutput: unknown): string | undefined {
|
|
24
|
+
if (typeof rawOutput === "string") return rawOutput;
|
|
25
|
+
if (Array.isArray(rawOutput)) {
|
|
26
|
+
return rawOutput
|
|
27
|
+
.filter((item: any) => item.type === "text" && typeof item.text === "string")
|
|
28
|
+
.map((item: any) => item.text as string)
|
|
29
|
+
.join("\n") || undefined;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
// ─────────────────────────────────────────────────────────────────
|
|
19
35
|
// Types
|
|
20
36
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -79,6 +95,20 @@ export class ACPOverMAPHandler {
|
|
|
79
95
|
this.defaultCwd = config.defaultCwd ?? process.cwd();
|
|
80
96
|
}
|
|
81
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Abort all active streams targeting a specific agent.
|
|
100
|
+
* Called when an agent is stopped via MAP protocol to interrupt
|
|
101
|
+
* any in-progress ACP prompt streaming.
|
|
102
|
+
*/
|
|
103
|
+
abortStreamsForAgent(agentId: AgentId): void {
|
|
104
|
+
for (const [streamId, streamState] of this.streams) {
|
|
105
|
+
if (streamState.agentId === agentId) {
|
|
106
|
+
console.error(`[ACP-over-MAP] Aborting stream ${streamId} for stopped agent ${agentId}`);
|
|
107
|
+
streamState.abortController.abort();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
/**
|
|
83
113
|
* Process an ACP request and return the response.
|
|
84
114
|
* @param targetAgentId - Target agent for the request
|
|
@@ -277,7 +307,7 @@ export class ACPOverMAPHandler {
|
|
|
277
307
|
streamState.agentId = existing.id;
|
|
278
308
|
this.sessionMapper.createMapping(sessionId as ACPSessionId, existing.id);
|
|
279
309
|
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
280
|
-
return {};
|
|
310
|
+
return { sessionId };
|
|
281
311
|
}
|
|
282
312
|
|
|
283
313
|
// Agent exists but no active session - resume it
|
|
@@ -287,7 +317,7 @@ export class ACPOverMAPHandler {
|
|
|
287
317
|
streamState.agentId = spawned.id;
|
|
288
318
|
this.sessionMapper.createMapping(sessionId as ACPSessionId, spawned.id);
|
|
289
319
|
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
290
|
-
return {};
|
|
320
|
+
return { sessionId };
|
|
291
321
|
}
|
|
292
322
|
|
|
293
323
|
// No existing agent found - create new with the specified session ID
|
|
@@ -302,7 +332,7 @@ export class ACPOverMAPHandler {
|
|
|
302
332
|
this.sessionMapper.createMapping(sessionId as ACPSessionId, spawned.id);
|
|
303
333
|
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
304
334
|
|
|
305
|
-
return {};
|
|
335
|
+
return { sessionId };
|
|
306
336
|
}
|
|
307
337
|
|
|
308
338
|
private async handleAuthenticate(_params: unknown): Promise<unknown> {
|
|
@@ -322,7 +352,9 @@ export class ACPOverMAPHandler {
|
|
|
322
352
|
messages?: Array<{ role: string; content: string }>;
|
|
323
353
|
}) ?? {};
|
|
324
354
|
|
|
325
|
-
|
|
355
|
+
// Prefer the server's resolved session ID (set during loadSession) over the
|
|
356
|
+
// client's acpContext.sessionId which may be stale (e.g., "_resolve_" sentinel)
|
|
357
|
+
const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
|
|
326
358
|
if (!sessionId) {
|
|
327
359
|
throw new Error("No session - call newSession or loadSession first");
|
|
328
360
|
}
|
|
@@ -380,6 +412,21 @@ export class ACPOverMAPHandler {
|
|
|
380
412
|
emitNotification(notification);
|
|
381
413
|
};
|
|
382
414
|
|
|
415
|
+
// Ensure conversation exists in EventStore for history persistence
|
|
416
|
+
this.ensureConversation(sessionId as ACPSessionId, agentId);
|
|
417
|
+
|
|
418
|
+
// Accumulate response content for history recording
|
|
419
|
+
const buffer: { parts: Array<{ type: "text"; text: string } | ({ type: "tool" } & Record<string, unknown>)> } = {
|
|
420
|
+
parts: [],
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Track latest plan for persistence
|
|
424
|
+
let latestPlan: Array<{ content: string; priority: string; status: string }> | null = null;
|
|
425
|
+
|
|
426
|
+
// Track tool info from initial tool_call events (title, name, input)
|
|
427
|
+
// so we can merge them when tool_call_update arrives with status "completed"
|
|
428
|
+
const toolInfoCache = new Map<string, { title?: string; name?: string; input?: unknown }>();
|
|
429
|
+
|
|
383
430
|
try {
|
|
384
431
|
// Stream responses from the agent
|
|
385
432
|
let updateCount = 0;
|
|
@@ -389,17 +436,76 @@ export class ACPOverMAPHandler {
|
|
|
389
436
|
return { stopReason: "cancelled" };
|
|
390
437
|
}
|
|
391
438
|
|
|
439
|
+
// Accumulate content for history persistence (preserving text/tool interleaving order)
|
|
440
|
+
const u = update as Record<string, unknown>;
|
|
441
|
+
const updateType = u.sessionUpdate as string ?? u.type as string;
|
|
442
|
+
if (updateType === "agent_message_chunk") {
|
|
443
|
+
const content = u.content as { type?: string; text?: string } | undefined;
|
|
444
|
+
if (content?.text) {
|
|
445
|
+
const last = buffer.parts[buffer.parts.length - 1];
|
|
446
|
+
if (last && last.type === "text") {
|
|
447
|
+
last.text += content.text;
|
|
448
|
+
} else {
|
|
449
|
+
buffer.parts.push({ type: "text", text: content.text });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} else if (updateType === "plan") {
|
|
453
|
+
const entries = (u as { entries?: Array<{ content: string; priority: string; status: string }> }).entries;
|
|
454
|
+
if (entries) {
|
|
455
|
+
latestPlan = entries;
|
|
456
|
+
}
|
|
457
|
+
} else if (updateType === "tool_call" || updateType === "tool_call_update") {
|
|
458
|
+
const toolCallId = u.toolCallId as string | undefined;
|
|
459
|
+
const status = u.status as string | undefined;
|
|
460
|
+
const meta = u._meta as { claudeCode?: { toolName?: string } } | undefined;
|
|
461
|
+
|
|
462
|
+
// Cache tool info from initial tool_call events
|
|
463
|
+
if (updateType === "tool_call" && toolCallId) {
|
|
464
|
+
toolInfoCache.set(toolCallId, {
|
|
465
|
+
title: u.title as string | undefined,
|
|
466
|
+
name: meta?.claudeCode?.toolName,
|
|
467
|
+
input: u.rawInput,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (status === "completed" || status === "failed") {
|
|
472
|
+
// Merge cached info for tool_call_update events that lack title/input
|
|
473
|
+
const cached = toolCallId ? toolInfoCache.get(toolCallId) : undefined;
|
|
474
|
+
buffer.parts.push({
|
|
475
|
+
type: "tool",
|
|
476
|
+
toolCallId,
|
|
477
|
+
title: u.title ?? cached?.title,
|
|
478
|
+
name: meta?.claudeCode?.toolName ?? cached?.name,
|
|
479
|
+
status: u.status,
|
|
480
|
+
input: u.rawInput ?? cached?.input,
|
|
481
|
+
output: extractToolOutput(u.rawOutput),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
392
486
|
// Stream the update back to the client
|
|
393
487
|
emitSessionUpdate(update);
|
|
394
488
|
updateCount++;
|
|
395
489
|
}
|
|
396
490
|
|
|
397
|
-
|
|
491
|
+
// Check if the loop ended because of cancellation
|
|
492
|
+
const wasCancelled = streamState.abortController.signal.aborted;
|
|
493
|
+
const stopReason = wasCancelled ? "cancelled" : "end_turn";
|
|
494
|
+
|
|
495
|
+
console.error(`[ACP-over-MAP] Prompt completed for agent ${agentId}, ${updateCount} updates, stopReason=${stopReason}`);
|
|
496
|
+
|
|
497
|
+
// Persist conversation turns for history
|
|
498
|
+
this.recordPromptTurns(sessionId as ACPSessionId, agentId, messageContent, buffer);
|
|
499
|
+
|
|
500
|
+
// Persist latest plan in EventStore for history loading across restarts
|
|
501
|
+
if (latestPlan) {
|
|
502
|
+
this.eventStore.updateAgentPlan(agentId as AgentId, latestPlan);
|
|
503
|
+
}
|
|
398
504
|
|
|
399
505
|
// Emit updated session info after prompt completes
|
|
400
506
|
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
401
507
|
|
|
402
|
-
return { stopReason
|
|
508
|
+
return { stopReason };
|
|
403
509
|
} catch (error) {
|
|
404
510
|
console.error(`[ACP-over-MAP] Prompt error:`, error);
|
|
405
511
|
return {
|
|
@@ -417,31 +523,36 @@ export class ACPOverMAPHandler {
|
|
|
417
523
|
sessionIdFromContext?: string,
|
|
418
524
|
): Promise<unknown> {
|
|
419
525
|
const { sessionId: paramSessionId } = (params as { sessionId?: string }) ?? {};
|
|
420
|
-
|
|
526
|
+
// Prefer server's resolved session ID over client's potentially stale one
|
|
527
|
+
const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
|
|
421
528
|
|
|
422
|
-
|
|
423
|
-
|
|
529
|
+
const agentId = sessionId
|
|
530
|
+
? this.sessionMapper.getAgentId(sessionId as ACPSessionId)
|
|
531
|
+
: streamState.agentId;
|
|
424
532
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
533
|
+
console.error(
|
|
534
|
+
`[ACP-over-MAP] Cancel - streamId=${streamState.streamId} sessionId=${sessionId} agentId=${agentId}`,
|
|
535
|
+
);
|
|
428
536
|
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
if (!agentId) {
|
|
432
|
-
return { cancelled: true };
|
|
433
|
-
}
|
|
537
|
+
// 1. Abort the for-await loop in handlePrompt so it stops yielding updates
|
|
538
|
+
streamState.abortController.abort();
|
|
434
539
|
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
540
|
+
// 2. Cancel the agent's active session (sends session/cancel to the subprocess)
|
|
541
|
+
// This does NOT terminate the agent — it only interrupts the current prompt.
|
|
542
|
+
// The subprocess stays alive and can accept new prompts.
|
|
543
|
+
// Use map/agents/stop for full agent termination.
|
|
544
|
+
if (agentId) {
|
|
545
|
+
const session = this.agentManager.getSession(agentId);
|
|
546
|
+
if (session) {
|
|
547
|
+
try {
|
|
548
|
+
await session.cancel();
|
|
549
|
+
console.error(`[ACP-over-MAP] Session cancelled for agent ${agentId}`);
|
|
550
|
+
} catch (error) {
|
|
551
|
+
console.warn(`[ACP-over-MAP] session.cancel() failed for ${agentId}:`, error);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
440
554
|
}
|
|
441
555
|
|
|
442
|
-
// Clean up
|
|
443
|
-
this.sessionMapper.removeMapping(sessionId as ACPSessionId);
|
|
444
|
-
|
|
445
556
|
return { cancelled: true };
|
|
446
557
|
}
|
|
447
558
|
|
|
@@ -538,11 +649,157 @@ export class ACPOverMAPHandler {
|
|
|
538
649
|
};
|
|
539
650
|
}
|
|
540
651
|
|
|
652
|
+
case "_macro/getHistory": {
|
|
653
|
+
const { sessionId, agentId: historyAgentId, limit } = methodParams as {
|
|
654
|
+
sessionId?: string;
|
|
655
|
+
agentId?: string;
|
|
656
|
+
limit?: number;
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// Resolve conversationId: prefer agentId lookup (resolves to the
|
|
660
|
+
// original session_id where turns were recorded), fall back to
|
|
661
|
+
// explicit sessionId. This allows history to survive across server
|
|
662
|
+
// restarts even when the ACP session ID changes (e.g., resume()
|
|
663
|
+
// fails → TUI creates new session with different ID).
|
|
664
|
+
let conversationId: string | undefined;
|
|
665
|
+
if (historyAgentId) {
|
|
666
|
+
const agent = this.eventStore.getAgent(historyAgentId as AgentId);
|
|
667
|
+
if (agent) {
|
|
668
|
+
conversationId = agent.session_id;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!conversationId) {
|
|
672
|
+
conversationId = sessionId;
|
|
673
|
+
}
|
|
674
|
+
if (!conversationId) {
|
|
675
|
+
return { turns: [] };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const turns = this.eventStore.listTurns({
|
|
679
|
+
conversationId,
|
|
680
|
+
order: "asc",
|
|
681
|
+
limit: limit ?? 200,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Include persisted plan and agent cwd if available
|
|
685
|
+
const agent = historyAgentId ? this.eventStore.getAgent(historyAgentId as AgentId) : undefined;
|
|
686
|
+
const plan = agent?.plan ?? [];
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
turns: turns.map((turn) => ({
|
|
690
|
+
role:
|
|
691
|
+
turn.contentType === "user_prompt"
|
|
692
|
+
? ("user" as const)
|
|
693
|
+
: ("assistant" as const),
|
|
694
|
+
timestamp: turn.timestamp,
|
|
695
|
+
content: turn.content,
|
|
696
|
+
})),
|
|
697
|
+
plan,
|
|
698
|
+
cwd: agent?.cwd ?? null,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
541
702
|
default:
|
|
542
703
|
throw new Error(`Unknown extension method: ${method}`);
|
|
543
704
|
}
|
|
544
705
|
}
|
|
545
706
|
|
|
707
|
+
// ─────────────────────────────────────────────────────────────────
|
|
708
|
+
// History Persistence
|
|
709
|
+
// ─────────────────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Ensure a conversation exists in the EventStore for a given session.
|
|
713
|
+
* This must be called before recording turns.
|
|
714
|
+
*/
|
|
715
|
+
private ensureConversation(
|
|
716
|
+
acpSessionId: ACPSessionId,
|
|
717
|
+
agentId: AgentId,
|
|
718
|
+
): void {
|
|
719
|
+
if (typeof this.eventStore.getConversation !== "function") {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const existing = this.eventStore.getConversation(acpSessionId);
|
|
724
|
+
if (existing) return;
|
|
725
|
+
|
|
726
|
+
try {
|
|
727
|
+
this.eventStore.emit({
|
|
728
|
+
type: "conversation",
|
|
729
|
+
source: { agent_id: agentId },
|
|
730
|
+
payload: {
|
|
731
|
+
action: "created",
|
|
732
|
+
conversation_id: acpSessionId,
|
|
733
|
+
conversation_type: "session",
|
|
734
|
+
subject: `ACP session ${acpSessionId}`,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
} catch (error) {
|
|
738
|
+
console.warn(
|
|
739
|
+
`[ACP-over-MAP] Failed to create conversation for session ${acpSessionId}:`,
|
|
740
|
+
error instanceof Error ? error.message : String(error),
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Record user and assistant turns after a prompt completes.
|
|
747
|
+
* Mirrors MacroAgent.recordPromptTurns() for the ACP-over-MAP path.
|
|
748
|
+
*/
|
|
749
|
+
private recordPromptTurns(
|
|
750
|
+
acpSessionId: ACPSessionId,
|
|
751
|
+
agentId: AgentId,
|
|
752
|
+
userMessage: string,
|
|
753
|
+
buffer: { parts: Array<{ type: "text"; text: string } | ({ type: "tool" } & Record<string, unknown>)> },
|
|
754
|
+
): void {
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
// Record user turn
|
|
759
|
+
if (userMessage) {
|
|
760
|
+
this.eventStore.emit({
|
|
761
|
+
type: "turn",
|
|
762
|
+
source: { agent_id: agentId },
|
|
763
|
+
payload: {
|
|
764
|
+
action: "recorded",
|
|
765
|
+
turn_id: `turn_user_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
|
766
|
+
conversation_id: acpSessionId,
|
|
767
|
+
participant: "user",
|
|
768
|
+
timestamp: now,
|
|
769
|
+
content_type: "user_prompt",
|
|
770
|
+
content: userMessage,
|
|
771
|
+
source_type: "explicit",
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Record assistant turn with accumulated content (parts already in order)
|
|
777
|
+
const parts = buffer.parts;
|
|
778
|
+
|
|
779
|
+
if (parts.length > 0) {
|
|
780
|
+
this.eventStore.emit({
|
|
781
|
+
type: "turn",
|
|
782
|
+
source: { agent_id: agentId },
|
|
783
|
+
payload: {
|
|
784
|
+
action: "recorded",
|
|
785
|
+
turn_id: `turn_asst_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
|
786
|
+
conversation_id: acpSessionId,
|
|
787
|
+
participant: agentId,
|
|
788
|
+
timestamp: now + 1,
|
|
789
|
+
content_type: "assistant_response",
|
|
790
|
+
content: { parts },
|
|
791
|
+
source_type: "explicit",
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
} catch (error) {
|
|
796
|
+
console.warn(
|
|
797
|
+
`[ACP-over-MAP] Failed to record turns for session ${acpSessionId}:`,
|
|
798
|
+
error instanceof Error ? error.message : String(error),
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
546
803
|
// ─────────────────────────────────────────────────────────────────
|
|
547
804
|
// Session Info
|
|
548
805
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
type ScopeId,
|
|
66
66
|
} from "../types.js";
|
|
67
67
|
import type { AgentId } from "../../store/types/index.js";
|
|
68
|
+
import type { AgentStopReason } from "../../agent/types.js";
|
|
68
69
|
import {
|
|
69
70
|
createConnectionManager,
|
|
70
71
|
type ConnectionManager,
|
|
@@ -712,6 +713,10 @@ export class MAPAdapterImpl implements MAPAdapter {
|
|
|
712
713
|
"map/agents/get": async (params) =>
|
|
713
714
|
this.handleGetAgent(participantId, params),
|
|
714
715
|
|
|
716
|
+
// Agent lifecycle
|
|
717
|
+
"map/agents/stop": async (params) =>
|
|
718
|
+
this.handleStopAgent(participantId, params),
|
|
719
|
+
|
|
715
720
|
// Scope queries
|
|
716
721
|
"map/scopes/list": async () => this.handleListScopes(participantId),
|
|
717
722
|
"map/scopes/get": async (params) =>
|
|
@@ -764,6 +769,7 @@ export class MAPAdapterImpl implements MAPAdapter {
|
|
|
764
769
|
"map/send": "canMessage",
|
|
765
770
|
"map/agents/list": "canQuery",
|
|
766
771
|
"map/agents/get": "canQuery",
|
|
772
|
+
"map/agents/stop": "canStop",
|
|
767
773
|
"map/scopes/list": "canQuery",
|
|
768
774
|
"map/scopes/get": "canQuery",
|
|
769
775
|
"map/scopes/create": "canManageScopes",
|
|
@@ -808,16 +814,27 @@ export class MAPAdapterImpl implements MAPAdapter {
|
|
|
808
814
|
break;
|
|
809
815
|
}
|
|
810
816
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
817
|
+
// Process messages concurrently (same pattern as toad reference).
|
|
818
|
+
// The message loop must not block on any handler, otherwise
|
|
819
|
+
// session/cancel can't be delivered while session/prompt is running.
|
|
820
|
+
// The stream writer queues writes safely, so concurrent responses
|
|
821
|
+
// don't interleave.
|
|
822
|
+
const processAndRespond = async () => {
|
|
823
|
+
const result = await session.rpcHandler.process(value, {
|
|
824
|
+
participantId: session.participantId,
|
|
825
|
+
capabilities: participant.capabilities,
|
|
826
|
+
signal: session.abortController.signal,
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
if (result.type === "response") {
|
|
830
|
+
await this.sendToSession(session, result.response);
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
processAndRespond().catch((err) => {
|
|
834
|
+
if (!session.abortController.signal.aborted) {
|
|
835
|
+
console.error(`[MAPAdapter] Error processing message:`, err);
|
|
836
|
+
}
|
|
815
837
|
});
|
|
816
|
-
|
|
817
|
-
// Send response if needed
|
|
818
|
-
if (result.type === "response") {
|
|
819
|
-
await this.sendToSession(session, result.response);
|
|
820
|
-
}
|
|
821
838
|
}
|
|
822
839
|
} catch (error) {
|
|
823
840
|
if (!session.abortController.signal.aborted) {
|
|
@@ -1181,6 +1198,59 @@ export class MAPAdapterImpl implements MAPAdapter {
|
|
|
1181
1198
|
return { agent: agent ?? null };
|
|
1182
1199
|
}
|
|
1183
1200
|
|
|
1201
|
+
private async handleStopAgent(
|
|
1202
|
+
_participantId: ParticipantId,
|
|
1203
|
+
params: unknown,
|
|
1204
|
+
): Promise<{ stopping: boolean; agent?: AgentInfo }> {
|
|
1205
|
+
const { agentId, reason, force } = (params as {
|
|
1206
|
+
agentId?: AgentId;
|
|
1207
|
+
reason?: string;
|
|
1208
|
+
force?: boolean;
|
|
1209
|
+
}) ?? {};
|
|
1210
|
+
|
|
1211
|
+
if (!agentId) {
|
|
1212
|
+
throw RPCError.invalidParams("agentId required");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (!this.services.agentManager) {
|
|
1216
|
+
throw RPCError.internalError("Agent manager not available");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Abort any active ACP streams for this agent
|
|
1220
|
+
if (this.acpOverMapHandler) {
|
|
1221
|
+
this.acpOverMapHandler.abortStreamsForAgent(agentId);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Terminate the agent
|
|
1225
|
+
try {
|
|
1226
|
+
await this.services.agentManager.terminate(
|
|
1227
|
+
agentId,
|
|
1228
|
+
(reason ?? "cancelled") as AgentStopReason,
|
|
1229
|
+
);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
console.error(`[MAPAdapter] Error stopping agent ${agentId}:`, error);
|
|
1232
|
+
throw RPCError.internalError(
|
|
1233
|
+
`Failed to stop agent: ${error instanceof Error ? error.message : String(error)}`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Emit agent state changed event for subscribers
|
|
1238
|
+
this.emitEvent({
|
|
1239
|
+
eventId: `stop-${Date.now()}`,
|
|
1240
|
+
type: "agent.state.changed" as MAPEventType,
|
|
1241
|
+
timestamp: Date.now(),
|
|
1242
|
+
agentId,
|
|
1243
|
+
data: {
|
|
1244
|
+
agentId,
|
|
1245
|
+
current: "stopped",
|
|
1246
|
+
previous: "running",
|
|
1247
|
+
reason: reason ?? "cancelled",
|
|
1248
|
+
},
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
return { stopping: true };
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1184
1254
|
private async handleListScopes(
|
|
1185
1255
|
participantId: ParticipantId,
|
|
1186
1256
|
): Promise<{ scopes: ScopeInfo[] }> {
|
|
@@ -674,8 +674,7 @@ describe('Event Archival', () => {
|
|
|
674
674
|
beforeEach(async () => {
|
|
675
675
|
// Create a temporary directory for testing
|
|
676
676
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'event-store-test-'));
|
|
677
|
-
|
|
678
|
-
store = await createEventStore({ path: storePath });
|
|
677
|
+
store = await createEventStore({ baseDir: testDir, instanceId: 'archive-test' });
|
|
679
678
|
});
|
|
680
679
|
|
|
681
680
|
afterEach(async () => {
|
|
@@ -987,18 +986,11 @@ describe('Event Archival', () => {
|
|
|
987
986
|
}
|
|
988
987
|
});
|
|
989
988
|
|
|
990
|
-
it('should
|
|
989
|
+
it('should throw when using removed legacy path option', async () => {
|
|
991
990
|
const legacyPath = path.join(testDir, 'legacy-store.json');
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
995
|
-
expect.stringContaining('DEPRECATION WARNING')
|
|
996
|
-
);
|
|
997
|
-
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
998
|
-
expect.stringContaining('path` option is deprecated')
|
|
991
|
+
await expect(createEventStore({ path: legacyPath })).rejects.toThrow(
|
|
992
|
+
'The `path` option has been removed'
|
|
999
993
|
);
|
|
1000
|
-
|
|
1001
|
-
await legacyStore.close();
|
|
1002
994
|
});
|
|
1003
995
|
|
|
1004
996
|
it('should not emit deprecation warning for new-style configuration', async () => {
|
|
@@ -1064,6 +1056,46 @@ describe('Event Archival', () => {
|
|
|
1064
1056
|
await store1.close();
|
|
1065
1057
|
await store2.close();
|
|
1066
1058
|
});
|
|
1059
|
+
|
|
1060
|
+
it('should preserve agent plan across reload (rebuildViews)', async () => {
|
|
1061
|
+
const planStore = await createEventStore({
|
|
1062
|
+
baseDir: testDir,
|
|
1063
|
+
instanceId: 'plan-reload-test',
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// Spawn an agent
|
|
1067
|
+
planStore.emit({
|
|
1068
|
+
type: 'spawn',
|
|
1069
|
+
source: { agent_id: 'agent_plan' },
|
|
1070
|
+
payload: {
|
|
1071
|
+
agent_id: 'agent_plan',
|
|
1072
|
+
session_id: 'sess_plan',
|
|
1073
|
+
task: 'test plan persistence',
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
// Set plan via updateAgentPlan
|
|
1078
|
+
const plan = [
|
|
1079
|
+
{ content: 'Read files', priority: 'high', status: 'completed' },
|
|
1080
|
+
{ content: 'Write code', priority: 'medium', status: 'in_progress' },
|
|
1081
|
+
];
|
|
1082
|
+
planStore.updateAgentPlan('agent_plan' as AgentId, plan);
|
|
1083
|
+
|
|
1084
|
+
// Verify plan is set
|
|
1085
|
+
const before = planStore.getAgent('agent_plan' as AgentId);
|
|
1086
|
+
expect(before?.plan).toEqual(plan);
|
|
1087
|
+
|
|
1088
|
+
// Persist, then reload (simulates server restart: load from SQLite + rebuildViews)
|
|
1089
|
+
await planStore.persist();
|
|
1090
|
+
await planStore.reload();
|
|
1091
|
+
|
|
1092
|
+
// Plan should survive the reload
|
|
1093
|
+
const after = planStore.getAgent('agent_plan' as AgentId);
|
|
1094
|
+
expect(after).toBeDefined();
|
|
1095
|
+
expect(after?.plan).toEqual(plan);
|
|
1096
|
+
|
|
1097
|
+
await planStore.close();
|
|
1098
|
+
});
|
|
1067
1099
|
});
|
|
1068
1100
|
});
|
|
1069
1101
|
|
|
@@ -90,7 +90,6 @@ describe('Path Resolution', () => {
|
|
|
90
90
|
|
|
91
91
|
expect(resolved.instancePath).toBe(':memory:');
|
|
92
92
|
expect(resolved.isNew).toBe(true);
|
|
93
|
-
expect(resolved.isLegacy).toBe(false);
|
|
94
93
|
expect(resolved.backendType).toBe('memory');
|
|
95
94
|
expect(resolved.instanceId).toMatch(/^inst_/);
|
|
96
95
|
});
|
|
@@ -142,15 +141,14 @@ describe('Path Resolution', () => {
|
|
|
142
141
|
expect(resolved.namespace).toBe('my-project');
|
|
143
142
|
});
|
|
144
143
|
|
|
145
|
-
it('should
|
|
144
|
+
it('should ignore legacy path option and use defaults', () => {
|
|
146
145
|
const legacyPath = path.join(testBaseDir, 'legacy-store.json');
|
|
147
|
-
const config: StoreConfig = { path: legacyPath };
|
|
146
|
+
const config: StoreConfig = { path: legacyPath, baseDir: testBaseDir };
|
|
148
147
|
const resolved = resolveInstancePath(config);
|
|
149
148
|
|
|
150
|
-
|
|
151
|
-
expect(resolved.backendType).toBe(
|
|
152
|
-
expect(resolved.
|
|
153
|
-
expect(resolved.instanceId).toBe('legacy-store');
|
|
149
|
+
// path option is no longer handled — resolveInstancePath ignores it
|
|
150
|
+
expect(resolved.backendType).toBe(DEFAULT_BACKEND_TYPE);
|
|
151
|
+
expect(resolved.instanceId).toMatch(/^inst_/);
|
|
154
152
|
});
|
|
155
153
|
|
|
156
154
|
it('should throw on invalid instance ID', () => {
|