refactor(agent): consolidate agent type definitions and add multi-agent-type support

This commit is contained in:
Mohamed Boudra
2025-10-26 18:03:03 +01:00
parent 700840da5a
commit edf47a24bc
22 changed files with 677 additions and 1224 deletions
@@ -38,6 +38,7 @@ describe("AgentManager", () => {
const agentId = await manager.createAgent({
cwd: tmpDir,
type: "claude",
initialMode: "plan",
});
createdAgents.push({ manager, agentId });
@@ -96,10 +97,11 @@ describe("AgentManager", () => {
}, 120000);
describe("persistence", () => {
it.only("should load persisted agent and send new prompt", async () => {
it("should load persisted agent and send new prompt", async () => {
const manager = new AgentManager();
const agentId = await manager.createAgent({
cwd: tmpDir,
type: "claude",
});
createdAgents.push({ manager, agentId });
@@ -115,7 +117,9 @@ describe("AgentManager", () => {
expect(status).toBe("completed");
const agentBeforeKill = manager.listAgents().find((a) => a.id === agentId);
const agentBeforeKill = manager
.listAgents()
.find((a) => a.id === agentId);
const claudeSessionId = manager.getClaudeSessionId(agentId);
console.log("Before kill - ACP Session ID:", agentBeforeKill?.sessionId);
console.log("Before kill - Claude Session ID:", claudeSessionId);
@@ -127,7 +131,9 @@ describe("AgentManager", () => {
await newManager.initialize();
// Update the tracking to use the new manager instance
const trackingIndex = createdAgents.findIndex((a) => a.agentId === agentId);
const trackingIndex = createdAgents.findIndex(
(a) => a.agentId === agentId
);
if (trackingIndex >= 0) {
createdAgents[trackingIndex] = { manager: newManager, agentId };
}
+172 -28
View File
@@ -17,6 +17,10 @@ import {
} from "@agentclientprotocol/sdk";
import { v4 as uuidv4 } from "uuid";
import { expandTilde } from "../terminal-mcp/tmux.js";
import {
getAgentModes,
getAgentTypeDefinition,
} from "./agent-types.js";
import type {
AgentStatus,
AgentInfo,
@@ -86,6 +90,84 @@ function getAgentError(state: ManagedAgentState): string | undefined {
return undefined;
}
function normalizeModes(modes?: SessionMode[] | null): SessionMode[] {
if (!modes) {
return [];
}
return modes.map((mode) => ({
id: mode.id,
name: mode.name,
description: mode.description ?? null,
}));
}
function getStaticModes(type: AgentOptions["type"]): SessionMode[] {
const staticModes = getAgentModes(type);
return staticModes.map((mode) => ({
id: mode.id,
name: mode.name,
description: mode.description ?? null,
}));
}
function buildAvailableModes(
type: AgentOptions["type"],
runtimeModes?: SessionMode[] | null
): SessionMode[] | null {
const runtimeList = normalizeModes(runtimeModes);
if (runtimeList.length > 0) {
return runtimeList;
}
const staticModes = getStaticModes(type);
return staticModes.length > 0 ? staticModes : null;
}
function resolveModeSelection({
type,
requestedModeId,
runtimeModes,
}: {
type: AgentOptions["type"];
requestedModeId?: string | null;
runtimeModes?: SessionMode[] | null;
}): {
availableModes: SessionMode[] | null;
modeId: string | null;
wasAdjusted: boolean;
} {
const availableModes = buildAvailableModes(type, runtimeModes);
if (!availableModes || availableModes.length === 0) {
return {
availableModes: null,
modeId: null,
wasAdjusted: Boolean(requestedModeId),
};
}
if (requestedModeId) {
const match = availableModes.find((mode) => mode.id === requestedModeId);
if (match) {
return {
availableModes,
modeId: requestedModeId,
wasAdjusted: false,
};
}
}
const definition = getAgentTypeDefinition(type);
const fallbackModeId =
definition.defaultModeId ?? (availableModes[0]?.id ?? null);
return {
availableModes,
modeId: fallbackModeId,
wasAdjusted: Boolean(requestedModeId),
};
}
/**
* Client implementation for ACP callbacks
*/
@@ -236,10 +318,26 @@ export class AgentManager {
}
const createdAt = new Date();
const agentOptions: AgentOptions = {
type: "claude",
sessionId: null,
};
const agentOptions: AgentOptions =
options.type === "claude"
? {
type: "claude",
sessionId: null,
}
: {
type: "codex",
};
const modeSelection = resolveModeSelection({
type: options.type,
requestedModeId: options.initialMode ?? null,
});
if (options.initialMode && modeSelection.wasAdjusted) {
console.warn(
`[AgentManager] Invalid initial mode '${options.initialMode}' for agent type '${options.type}'. Falling back to '${modeSelection.modeId ?? "none"}'.`
);
}
const agent: ManagedAgent = {
id: agentId,
@@ -254,9 +352,7 @@ export class AgentManager {
currentAssistantMessageId: null,
currentThoughtId: null,
titleGenerationTriggered: false,
pendingSessionMode: options.initialPrompt
? null
: options.initialMode ?? null,
pendingSessionMode: options.initialPrompt ? null : modeSelection.modeId,
state: {
type: "uninitialized",
persistedSessionId: null,
@@ -593,14 +689,17 @@ export class AgentManager {
const runtime = this.getRuntime(agent);
const sessionId = runtime?.sessionId ?? null;
const currentModeId = runtime?.currentModeId ?? null;
const availableModes = runtime?.availableModes ?? null;
const availableModes = buildAvailableModes(
agent.options.type,
runtime?.availableModes ?? null
);
return {
id: agent.id,
status,
createdAt: agent.createdAt,
lastActivityAt: agent.lastActivityAt,
type: "claude" as const,
type: agent.options.type,
sessionId,
error: error ?? null,
currentModeId,
@@ -659,17 +758,21 @@ export class AgentManager {
const status = getAgentStatusFromState(agent.state);
const error = getAgentError(agent.state);
const runtime = this.getRuntime(agent);
const availableModes = buildAvailableModes(
agent.options.type,
runtime?.availableModes ?? null
);
const info: AgentInfo = {
id: agent.id,
status,
createdAt: agent.createdAt,
lastActivityAt: agent.lastActivityAt,
type: "claude",
type: agent.options.type,
sessionId: runtime?.sessionId ?? null,
error: error ?? null,
currentModeId: runtime?.currentModeId ?? null,
availableModes: runtime?.availableModes ?? null,
availableModes,
title: agent.title,
cwd: agent.cwd,
};
@@ -701,7 +804,10 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found`);
}
const runtime = this.getRuntime(agent);
return runtime?.availableModes ?? null;
return buildAvailableModes(
agent.options.type,
runtime?.availableModes ?? null
);
}
/**
@@ -721,9 +827,12 @@ export class AgentManager {
throw new Error(`Agent ${agentId} has no active session`);
}
// Validate mode is available if we have the list
const availableModes = runtime.availableModes ?? [];
if (availableModes.length > 0) {
const availableModes = buildAvailableModes(
agent.options.type,
runtime.availableModes ?? null
);
if (availableModes && availableModes.length > 0) {
const mode = availableModes.find((m) => m.id === modeId);
if (!mode) {
const availableIds = availableModes.map((m) => m.id).join(", ");
@@ -742,6 +851,7 @@ export class AgentManager {
const updatedRuntime: AgentRuntime = {
...runtime,
currentModeId: modeId,
availableModes,
};
switch (agent.state.type) {
@@ -832,11 +942,13 @@ export class AgentManager {
// Create the init promise
const initPromise = (async () => {
try {
const definition = getAgentTypeDefinition(agent.options.type);
const hasPersistedSession =
(agent.options.type === "claude" &&
definition.supportsSessionPersistence &&
((agent.options.type === "claude" &&
agent.options.sessionId !== null) ||
!!persistedSessionId;
const mode = hasPersistedSession ? "resume" : "new";
!!persistedSessionId);
const mode: "new" | "resume" = hasPersistedSession ? "resume" : "new";
console.log(
`[Agent ${agentId}] Starting lazy initialization (mode: ${mode})`
@@ -965,7 +1077,8 @@ export class AgentManager {
throw new Error(errorMessage);
}
const agentProcess = spawn("npx", ["@boudra/claude-code-acp"], {
const definition = getAgentTypeDefinition(agent.options.type);
const agentProcess = spawn(definition.spawn.command, definition.spawn.args, {
stdio: ["pipe", "pipe", "pipe"],
cwd,
});
@@ -1040,11 +1153,15 @@ export class AgentManager {
},
});
const supportsResume = definition.supportsSessionPersistence;
const canResume = supportsResume && mode === "resume";
let sessionResponse:
| Awaited<ReturnType<typeof connection.newSession>>
| Awaited<ReturnType<typeof connection.loadSession>>;
let effectiveSessionId: string;
if (mode === "resume") {
if (canResume) {
const sessionIdToResume =
(agent.options.type === "claude" && agent.options.sessionId) ||
resumeSessionId ||
@@ -1064,7 +1181,13 @@ export class AgentManager {
});
effectiveSessionId = sessionIdToResume;
} else {
console.log(`[Agent ${agentId}] Creating new session`);
if (mode === "resume" && !supportsResume) {
console.log(
`[Agent ${agentId}] Resume requested but unsupported for type '${definition.id}', starting new session`
);
} else {
console.log(`[Agent ${agentId}] Creating new session`);
}
const newSessionResponse = await connection.newSession({
cwd,
mcpServers: [],
@@ -1074,16 +1197,37 @@ export class AgentManager {
}
runtime.sessionId = effectiveSessionId;
runtime.currentModeId = sessionResponse.modes?.currentModeId ?? null;
runtime.availableModes = sessionResponse.modes?.availableModes ?? null;
const claudeSessionId = sessionResponse._meta?.claudeSessionId as
| string
| undefined;
const modeSelection = resolveModeSelection({
type: agent.options.type,
requestedModeId: sessionResponse.modes?.currentModeId ?? null,
runtimeModes: sessionResponse.modes?.availableModes ?? null,
});
runtime.availableModes = modeSelection.availableModes;
runtime.currentModeId = modeSelection.modeId;
if (
sessionResponse.modes?.currentModeId &&
modeSelection.wasAdjusted
) {
console.warn(
`[Agent ${agentId}] Mode '${sessionResponse.modes.currentModeId}' not available for type '${agent.options.type}', using '${modeSelection.modeId ?? "none"}' instead.`
);
}
const claudeSessionId =
sessionResponse._meta?.claudeSessionId !== undefined
? (sessionResponse._meta?.claudeSessionId as string | undefined)
: undefined;
console.log(
`[Agent ${agentId}] Session ${
mode === "new" ? "created" : "loaded"
}: ACP=${effectiveSessionId}, Claude=${claudeSessionId || "N/A"}`
canResume ? "loaded" : "created"
}: ACP=${effectiveSessionId}${
agent.options.type === "claude"
? `, Claude=${claudeSessionId || "N/A"}`
: ""
}`
);
await this.updateAgent(agentId, (managedAgent) => {
@@ -13,7 +13,9 @@ export const AgentOptionsSchema = z.discriminatedUnion("type", [
type: z.literal("claude"),
sessionId: z.string().nullable(), // Claude's internal session ID (null until first prompt)
}),
// Add more agent types here in the future
z.object({
type: z.literal("codex"),
}),
]);
export type AgentOptions = z.infer<typeof AgentOptionsSchema>;
@@ -0,0 +1,90 @@
export type AgentType = "claude" | "codex";
export interface AgentModeDefinition {
id: string;
name: string;
description?: string | null;
}
export interface AgentTypeDefinition {
id: AgentType;
label: string;
description?: string;
spawn: {
command: string;
args: string[];
};
supportsSessionPersistence: boolean;
availableModes: AgentModeDefinition[];
defaultModeId: string;
}
const CLAUDE_MODES: AgentModeDefinition[] = [
{
id: "plan",
name: "Plan",
description: "Plan and design before implementing",
},
{
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skip permission prompts for faster execution",
},
];
const CODEX_MODES: AgentModeDefinition[] = [
{
id: "read-only",
name: "Read Only",
description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network",
},
{
id: "auto",
name: "Auto",
description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network",
},
{
id: "full-access",
name: "Full Access",
description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution",
},
];
const agentTypeDefinitions: Record<AgentType, AgentTypeDefinition> = {
claude: {
id: "claude",
label: "Claude Code",
description: "Full Claude Code agent with session persistence",
spawn: {
command: "npx",
args: ["@boudra/claude-code-acp"],
},
supportsSessionPersistence: true,
availableModes: CLAUDE_MODES,
defaultModeId: "plan",
},
codex: {
id: "codex",
label: "Codex",
description: "Zed Codex ACP integration (no session resume)",
spawn: {
command: "npx",
args: ["@zed-industries/codex-acp"],
},
supportsSessionPersistence: false,
availableModes: CODEX_MODES,
defaultModeId: "auto",
},
} as const;
export function getAgentTypeDefinition(type: AgentType): AgentTypeDefinition {
return agentTypeDefinitions[type];
}
export function getAgentModes(type: AgentType): AgentModeDefinition[] {
return agentTypeDefinitions[type].availableModes;
}
export function listAgentTypeDefinitions(): AgentTypeDefinition[] {
return Object.values(agentTypeDefinitions);
}
@@ -121,6 +121,7 @@ export async function createAgentMcpServer(
const agentId = await agentManager.createAgent({
cwd: resolvedCwd,
type: "claude",
initialPrompt,
initialMode,
});
@@ -1,88 +0,0 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
async function main() {
console.log("=== ACP Agent Manager Test ===\n");
const manager = new AgentManager();
try {
console.log("1. Creating agent...");
const agentId = await manager.createAgent({
cwd: process.cwd(),
});
console.log(` ✓ Agent created: ${agentId}\n`);
console.log("2. Subscribing to updates...");
manager.subscribeToUpdates(agentId, (update) => {
console.log(` [UPDATE] ${new Date().toISOString()}`);
console.log(` Type: ${(update.notification as any).type}`);
console.log(` Notification:`, JSON.stringify(update.notification, null, 2));
console.log();
});
console.log(" ✓ Subscribed\n");
console.log("3. Checking initial status...");
const status = manager.getAgentStatus(agentId);
console.log(` Status: ${status}\n`);
console.log("4. Listing all agents...");
const agents = manager.listAgents();
console.log(` Total agents: ${agents.length}`);
agents.forEach((agent) => {
console.log(
` - ${agent.id}: ${agent.status} (created: ${agent.createdAt.toISOString()})`
);
});
console.log();
console.log("5. Sending prompt: 'List files in current directory'");
await manager.sendPrompt(
agentId,
"List files in current directory using ls"
);
console.log(" ✓ Prompt sent\n");
console.log("6. Waiting for completion (30 seconds max)...");
const startTime = Date.now();
const maxWait = 30000; // 30 seconds
await new Promise<void>((resolve, reject) => {
const checkInterval = setInterval(() => {
const currentStatus = manager.getAgentStatus(agentId);
const elapsed = Date.now() - startTime;
if (currentStatus === "completed") {
clearInterval(checkInterval);
console.log(` ✓ Agent completed in ${elapsed}ms\n`);
resolve();
} else if (currentStatus === "failed") {
clearInterval(checkInterval);
const agents = manager.listAgents();
const agent = agents.find((a) => a.id === agentId);
reject(new Error(`Agent failed: ${agent?.error || "Unknown error"}`));
} else if (elapsed > maxWait) {
clearInterval(checkInterval);
console.log(` ⚠ Timeout after ${elapsed}ms (status: ${currentStatus})\n`);
resolve();
}
}, 500);
});
console.log("7. Cleaning up...");
await manager.killAgent(agentId);
console.log(" ✓ Agent killed\n");
console.log("=== Test completed successfully ===");
} catch (error) {
console.error("\n❌ Test failed:");
console.error(error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -1,610 +0,0 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
import { mkdtemp, rm, readFile, access } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { AgentUpdate, AgentStatus } from "./types.js";
/**
* Comprehensive Test Suite for ACP Agent Functionality
*
* This test validates all core features with a REAL Claude Code agent (no mocking):
*
* 1. Directory Control - Agent runs in specified directory
* 2. Permission Mode: Auto Approve - File operations are automatically approved
* 3. Permission Mode: Reject All - File operations are blocked
* 4. Multiple Prompts - Agent can handle sequential prompts
* 5. Update Streaming - All update types stream correctly (message chunks, tool calls, etc.)
* 6. State Management - Status accurately reflects actual agent state
*
* Run with: npx tsx src/server/acp/test-comprehensive.ts
*/
interface TestContext {
manager: AgentManager;
tmpDir: string;
}
interface CollectedUpdates {
all: AgentUpdate[];
messageChunks: string[];
toolCalls: any[];
toolResults: any[];
statusUpdates: any[];
commands: any[];
}
async function main() {
const manager = new AgentManager();
const tmpDir = await mkdtemp(join(tmpdir(), "acp-test-"));
console.log("=== Comprehensive ACP Agent Test Suite ===");
console.log(`Test directory: ${tmpDir}\n`);
const ctx: TestContext = { manager, tmpDir };
try {
await testDirectoryAndInitialPrompt(ctx);
await testPermissionAutoApprove(ctx);
await testPermissionRejectAll(ctx);
await testMultiplePrompts(ctx);
await testUpdateStreaming(ctx);
await testStateManagement(ctx);
console.log("\n✅ ALL TESTS PASSED!");
} catch (error) {
console.error("\n❌ TEST SUITE FAILED:", error);
throw error;
} finally {
// Cleanup
console.log("\n=== Cleanup ===");
const agents = manager.listAgents();
console.log(`Killing ${agents.length} agents...`);
for (const agent of agents) {
await manager.killAgent(agent.id);
}
console.log(`Removing test directory: ${tmpDir}`);
await rm(tmpDir, { recursive: true, force: true });
console.log("Cleanup complete");
}
}
/**
* TEST 1: Directory Control and Initial Prompt
* Verifies that the agent runs in the specified directory
* and can execute the initial prompt successfully
*/
async function testDirectoryAndInitialPrompt(ctx: TestContext) {
console.log("\n=== TEST 1: Directory Control and Initial Prompt ===");
const updates = createUpdateCollector();
let unsubscribe: (() => void) | null = null;
// Create agent without initial prompt first, so we can subscribe
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
// Subscribe before sending prompt
unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
// Now send the prompt
await ctx.manager.sendPrompt(
agentId,
"Run pwd and confirm you're in the test directory. Output the full path."
);
try {
// Wait for processing to complete
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(` - Total updates: ${updates.all.length}`);
console.log(` - Message chunks: ${updates.messageChunks.length}`);
console.log(` - Tool calls: ${updates.toolCalls.length}`);
// Verify the output contains the tmpDir path
const fullMessage = updates.messageChunks.join("");
if (!fullMessage.includes(ctx.tmpDir)) {
throw new Error(
`Expected output to contain tmpDir path "${ctx.tmpDir}", but got: ${fullMessage}`
);
}
console.log(`✓ Output contains correct directory path`);
// Verify we got tool calls (pwd command should be executed via terminal)
if (updates.toolCalls.length === 0) {
console.log(`⚠ Warning: No tool calls detected (expected pwd command)`);
} else {
console.log(`✓ Tool calls detected: ${updates.toolCalls.length}`);
}
// Verify status transitions
const statuses = extractStatusTransitions(updates);
if (statuses.length > 0) {
console.log(`✓ Status transitions: ${statuses.join(" → ")}`);
}
console.log("✅ TEST 1 PASSED\n");
} finally {
if (unsubscribe) unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 2: Permission Mode - Auto Approve
* Verifies that file operations are automatically approved
*/
async function testPermissionAutoApprove(ctx: TestContext) {
console.log("\n=== TEST 2: Permission Mode - Auto Approve ===");
const updates = createUpdateCollector();
const testFile = join(ctx.tmpDir, "test-auto-approve.txt");
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await ctx.manager.sendPrompt(
agentId,
`Create a file called "test-auto-approve.txt" with the content "hello from auto approve". You can write the file however you prefer.`
);
try {
// Wait for completion
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(` - Total updates: ${updates.all.length}`);
console.log(` - Tool calls: ${updates.toolCalls.length}`);
// Verify file was created
try {
const content = await readFile(testFile, "utf-8");
console.log(`✓ File created with content: "${content.trim()}"`);
if (!content.includes("hello from auto approve")) {
throw new Error(
`Expected file content to include "hello from auto approve", got: ${content}`
);
}
console.log(`✓ File content is correct`);
} catch (error) {
throw new Error(`File was not created or could not be read: ${error}`);
}
console.log("✅ TEST 2 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 3: Permission Mode - Reject All
* Verifies that file operations are blocked
*/
async function testPermissionRejectAll(ctx: TestContext) {
console.log("\n=== TEST 3: Permission Mode - Reject All ===");
const updates = createUpdateCollector();
const testFile = join(ctx.tmpDir, "test-blocked.txt");
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await ctx.manager.sendPrompt(
agentId,
`Create a file called "test-blocked.txt" with the content "this should be blocked".`
);
try {
// Wait for completion (agent should complete even if permission denied)
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(` - Total updates: ${updates.all.length}`);
// Verify file was NOT created
try {
await access(testFile);
throw new Error(
"File should not have been created with reject_all mode"
);
} catch (error: any) {
if (error.code === "ENOENT") {
console.log(`✓ File was correctly blocked from creation`);
} else {
throw error;
}
}
// Check that the response indicates permission denial
const fullMessage = updates.messageChunks.join("").toLowerCase();
const hasPermissionMessage =
fullMessage.includes("permission") ||
fullMessage.includes("denied") ||
fullMessage.includes("blocked") ||
fullMessage.includes("unable") ||
fullMessage.includes("could not") ||
fullMessage.includes("cannot");
if (hasPermissionMessage) {
console.log(`✓ Agent indicated permission denial in response`);
} else {
console.log(
`⚠ Warning: Agent response doesn't clearly indicate permission denial`
);
console.log(` Response: ${fullMessage.substring(0, 200)}...`);
}
console.log("✅ TEST 3 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 4: Multiple Prompts
* Verifies that an agent can handle multiple sequential prompts
*/
async function testMultiplePrompts(ctx: TestContext) {
console.log("\n=== TEST 4: Multiple Prompts ===");
const updates1 = createUpdateCollector();
const updates2 = createUpdateCollector();
let collectingFirst = true;
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
if (collectingFirst) {
collectUpdate(updates1, update);
} else {
collectUpdate(updates2, update);
}
});
try {
// Send first prompt
console.log("Sending prompt 1...");
await ctx.manager.sendPrompt(agentId, "Echo 'first prompt response'");
await waitForStatus(ctx.manager, agentId, "completed", 30000);
console.log(`✓ First prompt completed`);
console.log(` - Updates: ${updates1.all.length}`);
console.log(` - Message: ${updates1.messageChunks.join("").substring(0, 100)}...`);
// Switch to collecting second set of updates
collectingFirst = false;
// Send second prompt
console.log("Sending prompt 2...");
await ctx.manager.sendPrompt(agentId, "Echo 'second prompt response'");
await waitForStatus(ctx.manager, agentId, "completed", 30000);
console.log(`✓ Second prompt completed`);
console.log(` - Updates: ${updates2.all.length}`);
console.log(` - Message: ${updates2.messageChunks.join("").substring(0, 100)}...`);
// Verify both prompts got responses
if (updates1.all.length === 0 || updates2.all.length === 0) {
throw new Error("Both prompts should generate updates");
}
console.log(`✓ Both prompts executed successfully`);
console.log("✅ TEST 4 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 5: Update Streaming
* Verifies that all update types are received correctly
*/
async function testUpdateStreaming(ctx: TestContext) {
console.log("\n=== TEST 5: Update Streaming ===");
const updates = createUpdateCollector();
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
const unsubscribe = ctx.manager.subscribeToUpdates(agentId, (update) => {
collectUpdate(updates, update);
});
await ctx.manager.sendPrompt(
agentId,
"List the files in the current directory using the Bash tool. Then explain what you found."
);
try {
await waitForStatus(ctx.manager, agentId, "completed", 60000);
console.log(`✓ Agent completed processing`);
console.log(`\nUpdate Statistics:`);
console.log(` - Total updates: ${updates.all.length}`);
console.log(` - Message chunks: ${updates.messageChunks.length}`);
console.log(` - Tool calls: ${updates.toolCalls.length}`);
console.log(` - Tool results: ${updates.toolResults.length}`);
console.log(` - Status updates: ${updates.statusUpdates.length}`);
console.log(` - Commands: ${updates.commands.length}`);
// Verify message can be reconstructed
const fullMessage = updates.messageChunks.join("");
console.log(`\nReconstructed message length: ${fullMessage.length} chars`);
console.log(`First 150 chars: ${fullMessage.substring(0, 150)}...`);
if (fullMessage.length === 0) {
throw new Error("Expected to receive message chunks");
}
console.log(`✓ Message chunks successfully reconstructed`);
// Verify we got tool calls
if (updates.toolCalls.length === 0) {
throw new Error("Expected at least one tool call");
}
console.log(`✓ Tool calls captured: ${updates.toolCalls.length}`);
// Log tool call details
updates.toolCalls.forEach((call, i) => {
console.log(` Tool ${i + 1}: ${JSON.stringify(call).substring(0, 100)}...`);
});
console.log("✅ TEST 5 PASSED\n");
} finally {
unsubscribe();
await ctx.manager.killAgent(agentId);
}
}
/**
* TEST 6: State Management
* Verifies that agent status accurately reflects actual state
*/
async function testStateManagement(ctx: TestContext) {
console.log("\n=== TEST 6: State Management ===");
const statusLog: Array<{ time: Date; status: AgentStatus }> = [];
const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir,
});
console.log(`✓ Agent created: ${agentId}`);
await ctx.manager.sendPrompt(
agentId,
"Sleep for 2 seconds using a bash command, then echo 'done'"
);
// Track status changes
const checkInterval = setInterval(() => {
const status = ctx.manager.getAgentStatus(agentId);
statusLog.push({ time: new Date(), status });
}, 500);
try {
// Wait for completion
await waitForStatus(ctx.manager, agentId, "completed", 60000);
clearInterval(checkInterval);
console.log(`✓ Agent completed processing`);
console.log(`\nStatus transitions logged: ${statusLog.length}`);
// Analyze status transitions
const transitions: string[] = [];
let lastStatus: AgentStatus | null = null;
for (const entry of statusLog) {
if (entry.status !== lastStatus) {
transitions.push(entry.status);
lastStatus = entry.status;
}
}
console.log(`Status transition sequence: ${transitions.join(" → ")}`);
// Verify expected transitions occurred
const expectedStates = ["ready", "processing", "completed"];
for (const expectedState of expectedStates) {
if (!transitions.includes(expectedState as AgentStatus)) {
console.log(
`⚠ Warning: Expected state "${expectedState}" not observed`
);
} else {
console.log(`✓ State "${expectedState}" observed`);
}
}
// Verify final status is completed
const finalStatus = ctx.manager.getAgentStatus(agentId);
if (finalStatus !== "completed") {
throw new Error(
`Expected final status to be "completed", got "${finalStatus}"`
);
}
console.log(`✓ Final status is correct: ${finalStatus}`);
// Kill the agent and verify status changes
await ctx.manager.killAgent(agentId);
// Agent should be removed from manager
try {
ctx.manager.getAgentStatus(agentId);
throw new Error("Agent should be removed after killing");
} catch (error: any) {
if (error.message.includes("not found")) {
console.log(`✓ Agent correctly removed after kill`);
} else {
throw error;
}
}
console.log("✅ TEST 6 PASSED\n");
} finally {
clearInterval(checkInterval);
}
}
/**
* Helper: Create an update collector
*/
function createUpdateCollector(): CollectedUpdates {
return {
all: [],
messageChunks: [],
toolCalls: [],
toolResults: [],
statusUpdates: [],
commands: [],
};
}
/**
* Helper: Collect and categorize an update
*/
function collectUpdate(collector: CollectedUpdates, update: AgentUpdate): void {
collector.all.push(update);
const notification = update.notification as any;
// The notification structure can be:
// 1. { type: "sessionUpdate", sessionUpdate: { ... } } - for status updates
// 2. { sessionUpdate: "agent_message_chunk", content: { ... } } - for message chunks
// 3. { sessionUpdate: "tool_call", ... } - for tool calls
// 4. { availableCommands: [...] } - for available commands
// Message chunks
if (notification.sessionUpdate === "agent_message_chunk" && notification.content) {
const content = notification.content;
if (content.type === "text" && content.text) {
collector.messageChunks.push(content.text);
}
}
// Tool calls
if (notification.sessionUpdate === "tool_call") {
collector.toolCalls.push(notification);
}
// Tool results
if (notification.sessionUpdate === "tool_result") {
collector.toolResults.push(notification);
}
// Status updates from the wrapper
if (notification.type === "sessionUpdate" && notification.sessionUpdate?.status) {
collector.statusUpdates.push(notification.sessionUpdate.status);
}
// Status updates from our custom type
if (notification.sessionUpdate === "status_change") {
collector.statusUpdates.push(notification.status);
}
// Available commands
if (notification.availableCommands) {
collector.commands.push(notification.availableCommands);
}
}
/**
* Helper: Extract status transitions from updates
*/
function extractStatusTransitions(collector: CollectedUpdates): string[] {
const statuses: string[] = [];
let lastStatus: string | null = null;
for (const statusUpdate of collector.statusUpdates) {
if (statusUpdate !== lastStatus) {
statuses.push(String(statusUpdate));
lastStatus = statusUpdate;
}
}
return statuses;
}
/**
* Helper: Wait for agent to reach a specific status
*/
async function waitForStatus(
manager: AgentManager,
agentId: string,
targetStatus: AgentStatus,
timeoutMs: number
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const currentStatus = manager.getAgentStatus(agentId);
if (currentStatus === targetStatus) {
return;
}
if (currentStatus === "failed") {
const agent = manager.listAgents().find((a) => a.id === agentId);
throw new Error(
`Agent failed while waiting for status "${targetStatus}": ${agent?.error}`
);
}
// Wait a bit before checking again
await sleep(500);
}
throw new Error(
`Timeout waiting for status "${targetStatus}" (current: ${manager.getAgentStatus(agentId)})`
);
}
/**
* Helper: Sleep for specified milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Run the test suite
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -1,80 +0,0 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
async function main() {
console.log("=== ACP Concurrent Agents Test ===\n");
const manager = new AgentManager();
try {
console.log("1. Creating multiple agents...");
const agent1Id = await manager.createAgent({
cwd: process.cwd(),
});
const agent2Id = await manager.createAgent({
cwd: process.cwd(),
});
console.log(` ✓ Agent 1: ${agent1Id}`);
console.log(` ✓ Agent 2: ${agent2Id}\n`);
console.log("2. Setting up update handlers...");
let agent1Updates = 0;
let agent2Updates = 0;
manager.subscribeToUpdates(agent1Id, () => {
agent1Updates++;
});
manager.subscribeToUpdates(agent2Id, () => {
agent2Updates++;
});
console.log(" ✓ Handlers set up\n");
console.log("3. Sending prompts to both agents...");
await manager.sendPrompt(agent1Id, "Echo 'Agent 1 says hello'");
await manager.sendPrompt(agent2Id, "Echo 'Agent 2 says hello'");
console.log(" ✓ Prompts sent\n");
console.log("4. Waiting for activity (5 seconds)...");
await new Promise((resolve) => setTimeout(resolve, 5000));
console.log(` Agent 1 updates: ${agent1Updates}`);
console.log(` Agent 2 updates: ${agent2Updates}\n`);
console.log("5. Listing all agents...");
const agents = manager.listAgents();
console.log(` Total agents: ${agents.length}`);
agents.forEach((agent) => {
console.log(` - ${agent.id.slice(0, 8)}: ${agent.status}`);
});
console.log();
console.log("6. Testing cancellation on agent 1...");
const status1Before = manager.getAgentStatus(agent1Id);
console.log(` Agent 1 status before cancel: ${status1Before}`);
await manager.cancelAgent(agent1Id);
const status1After = manager.getAgentStatus(agent1Id);
console.log(` Agent 1 status after cancel: ${status1After}\n`);
console.log("7. Cleaning up...");
await manager.killAgent(agent1Id);
await manager.killAgent(agent2Id);
console.log(" ✓ All agents killed\n");
console.log("8. Verifying cleanup...");
const remainingAgents = manager.listAgents();
console.log(` Remaining agents: ${remainingAgents.length}\n`);
console.log("=== Concurrent test completed successfully ===");
} catch (error) {
console.error("\n❌ Test failed:");
console.error(error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
@@ -1,166 +0,0 @@
#!/usr/bin/env tsx
/**
* Integration test for Agent MCP Server with Session
*
* This test simulates a Session-like scenario where:
* 1. AgentManager is created
* 2. Agent MCP server is set up
* 3. Tools are called via the MCP client
* 4. Agent updates are streamed to a mock WebSocket handler
* 5. Cleanup is performed
*/
import { AgentManager } from "./agent-manager.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { experimental_createMCPClient } from "ai";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { AgentUpdate } from "./types.js";
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function main() {
console.log("=== Agent MCP Integration Test ===\n");
// Step 1: Create AgentManager
console.log("1. Creating AgentManager...");
const agentManager = new AgentManager();
console.log(" ✓ AgentManager created\n");
// Step 2: Create Agent MCP Server
console.log("2. Creating Agent MCP Server...");
const server = await createAgentMcpServer({ agentManager });
console.log(" ✓ Agent MCP Server created\n");
// Step 3: Set up transport and client
console.log("3. Setting up MCP transport and client...");
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const mcpClient = await experimental_createMCPClient({
transport: clientTransport,
});
console.log(" ✓ MCP client connected\n");
// Step 4: Get tools from client
console.log("4. Getting tools from MCP client...");
const tools = await mcpClient.tools();
const toolNames = Object.keys(tools);
console.log(` ✓ Got ${toolNames.length} tools: ${toolNames.join(", ")}\n`);
// Step 5: Verify all expected tools are present
console.log("5. Verifying expected tools...");
const expectedTools = [
"create_coding_agent",
"send_agent_prompt",
"get_agent_status",
"list_agents",
"cancel_agent",
"kill_agent",
];
const missingTools = expectedTools.filter((t) => !toolNames.includes(t));
if (missingTools.length > 0) {
console.error(` ✗ Missing tools: ${missingTools.join(", ")}`);
process.exit(1);
}
console.log(" ✓ All expected tools present\n");
// Step 6: Test direct AgentManager usage (bypassing MCP tools for simplicity)
console.log("6. Creating agent directly via AgentManager...");
const receivedUpdates: AgentUpdate[] = [];
const agentId = await agentManager.createAgent({
initialPrompt: "Create a simple hello world function in TypeScript",
cwd: process.cwd(),
});
console.log(` ✓ Agent created with ID: ${agentId}\n`);
// Step 7: Subscribe to agent updates (simulating Session behavior)
console.log("7. Subscribing to agent updates...");
const unsubscribe = agentManager.subscribeToUpdates(agentId, (update) => {
const updateType = (update.notification as any).type || "unknown";
console.log(` 📡 Received update for agent ${update.agentId}: ${updateType}`);
receivedUpdates.push(update);
});
console.log(" ✓ Subscribed to agent updates\n");
// Step 8: Wait a bit for agent initialization
console.log("8. Waiting for agent to initialize...");
await sleep(2000);
console.log(" ✓ Wait complete\n");
// Step 9: Test list_agents via manager
console.log("9. Listing agents via AgentManager...");
const agents = agentManager.listAgents();
console.log(` ✓ Found ${agents.length} agent(s)`);
console.log(` Agents:`, agents.map(a => ({ id: a.id, status: a.status })));
console.log();
// Step 10: Test get_agent_status
console.log("10. Getting agent status...");
const status = agentManager.getAgentStatus(agentId);
console.log(` ✓ Agent status: ${status}\n`);
// Step 11: Test send_agent_prompt
console.log("11. Sending prompt to agent...");
try {
await agentManager.sendPrompt(agentId, "Show me what you created");
console.log(" ✓ Prompt sent successfully\n");
} catch (error) {
console.log(` ⚠ Prompt send failed (expected if agent not ready): ${error instanceof Error ? error.message : String(error)}\n`);
}
// Step 12: Wait a bit for any agent processing
console.log("12. Waiting for agent processing...");
await sleep(3000);
console.log(" ✓ Wait complete\n");
// Step 13: Test cancel_agent
console.log("13. Cancelling agent...");
try {
await agentManager.cancelAgent(agentId);
console.log(" ✓ Agent cancelled successfully\n");
} catch (error) {
console.log(` ⚠ Cancel failed (expected if agent not processing): ${error instanceof Error ? error.message : String(error)}\n`);
}
// Step 14: Unsubscribe from updates
console.log("14. Unsubscribing from agent updates...");
unsubscribe();
console.log(" ✓ Unsubscribed\n");
// Step 15: Test kill_agent
console.log("15. Killing agent...");
await agentManager.killAgent(agentId);
console.log(" ✓ Agent killed successfully\n");
// Step 16: Verify agent is gone
console.log("16. Verifying agent is removed...");
await sleep(1000);
const finalAgents = agentManager.listAgents();
if (finalAgents.some((a) => a.id === agentId)) {
console.error(" ✗ Agent still exists after kill");
process.exit(1);
}
console.log(" ✓ Agent successfully removed\n");
// Step 17: Close MCP client
console.log("17. Closing MCP client...");
await mcpClient.close();
console.log(" ✓ MCP client closed\n");
// Summary
console.log("=== Test Summary ===");
console.log(`✓ All tests passed`);
console.log(`✓ Received ${receivedUpdates.length} agent updates during test`);
console.log("\nIntegration test completed successfully!");
}
// Run the test
main().catch((error) => {
console.error("\n❌ Test failed with error:");
console.error(error);
process.exit(1);
});
@@ -1,65 +0,0 @@
#!/usr/bin/env tsx
import { AgentManager } from "./agent-manager.js";
async function main() {
console.log("=== ACP Simple Test ===\n");
const manager = new AgentManager();
let updateCount = 0;
let toolCallCount = 0;
let messageChunks: string[] = [];
try {
console.log("1. Creating agent...");
const agentId = await manager.createAgent({
cwd: process.cwd(),
});
console.log(` ✓ Created: ${agentId}\n`);
console.log("2. Setting up minimal update handler...");
manager.subscribeToUpdates(agentId, (update) => {
updateCount++;
const sessionUpdate = (update.notification as any).sessionUpdate;
if (sessionUpdate === "tool_call") {
toolCallCount++;
console.log(` [Tool Call] ${(update.notification as any).title}`);
} else if (sessionUpdate === "agent_message_chunk") {
const text = (update.notification as any).content?.text || "";
if (text.trim()) {
messageChunks.push(text);
}
}
});
console.log(" ✓ Handler set up\n");
console.log("3. Sending prompt...");
await manager.sendPrompt(agentId, "List 3 files in current directory");
console.log(" ✓ Prompt sent\n");
console.log("4. Waiting for completion (10 seconds)...");
await new Promise((resolve) => setTimeout(resolve, 10000));
console.log("\n=== Results ===");
console.log(`Total updates: ${updateCount}`);
console.log(`Tool calls: ${toolCallCount}`);
console.log(`Message length: ${messageChunks.join("").length} chars`);
console.log(`First few chunks: ${messageChunks.slice(0, 5).join("")}\n`);
console.log("5. Cleaning up...");
await manager.killAgent(agentId);
console.log(" ✓ Agent killed\n");
console.log("=== Simple test completed ===");
} catch (error) {
console.error("\n❌ Test failed:");
console.error(error);
process.exit(1);
}
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+6 -7
View File
@@ -4,6 +4,7 @@ import type {
ClientSideConnection,
} from "@agentclientprotocol/sdk";
import type { ChildProcess } from "child_process";
import type { AgentModeDefinition, AgentType } from "./agent-types.js";
/**
* Extended update types with messageId for proper deduplication
@@ -68,13 +69,10 @@ export type AgentStatus =
| "killed";
/**
* Session mode definition from ACP
* Session mode definition from ACP.
* Alias to the shared agent mode definition used across the app.
*/
export interface SessionMode {
id: string;
name: string;
description?: string | null;
}
export type SessionMode = AgentModeDefinition;
/**
* Runtime state for an initialized agent
@@ -117,7 +115,7 @@ export interface AgentInfo {
status: AgentStatus;
createdAt: Date;
lastActivityAt: Date;
type: "claude";
type: AgentType;
sessionId: string | null;
error: string | null;
currentModeId: string | null;
@@ -141,6 +139,7 @@ export interface AgentUpdate {
*/
export interface CreateAgentOptions {
cwd: string;
type: AgentType;
initialPrompt?: string;
initialMode?: string;
}
+7 -2
View File
@@ -1,4 +1,6 @@
import { z } from "zod";
import type { AgentType } from "./acp/agent-types.js";
import { listAgentTypeDefinitions } from "./acp/agent-types.js";
const AgentModeSchema = z.object({
id: z.string(),
@@ -6,12 +8,14 @@ const AgentModeSchema = z.object({
description: z.string().nullable().optional(),
});
const agentTypes = listAgentTypeDefinitions().map((definition) => definition.id);
const AgentInfoSchema = z.object({
id: z.string(),
status: z.string(),
createdAt: z.date(),
lastActivityAt: z.date(),
type: z.literal("claude"),
type: z.enum(agentTypes as [AgentType, ...AgentType[]]),
sessionId: z.string().nullable(),
error: z.string().nullable(),
currentModeId: z.string().nullable(),
@@ -95,6 +99,7 @@ export const CreateAgentRequestMessageSchema = z.object({
cwd: z.string(),
initialMode: z.string().optional(),
worktreeName: z.string().optional(),
agentType: z.enum(agentTypes as [AgentType, ...AgentType[]]).optional(),
requestId: z.string().optional(),
});
@@ -220,7 +225,7 @@ export const AgentCreatedMessageSchema = z.object({
payload: z.object({
agentId: z.string(),
status: z.string(),
type: z.literal("claude"),
type: z.enum(agentTypes as [AgentType, ...AgentType[]]),
currentModeId: z.string().optional(),
availableModes: z.array(AgentModeSchema).optional(),
title: z.string().optional(),
+26 -14
View File
@@ -29,6 +29,7 @@ import { createTerminalMcpServer } from "./terminal-mcp/index.js";
import { AgentManager } from "./acp/agent-manager.js";
import { createAgentMcpServer } from "./acp/mcp-server.js";
import type { AgentUpdate } from "./acp/types.js";
import type { AgentType } from "./acp/agent-types.js";
import {
generateAgentTitle,
isTitleGeneratorInitialized,
@@ -493,12 +494,13 @@ export class Session {
break;
case "create_agent_request":
await this.handleCreateAgentRequest(
msg.cwd,
msg.initialMode,
msg.requestId,
msg.worktreeName
);
await this.handleCreateAgentRequest({
cwd: msg.cwd,
initialMode: msg.initialMode,
requestId: msg.requestId,
worktreeName: msg.worktreeName,
agentType: msg.agentType,
});
break;
case "initialize_agent_request":
@@ -820,12 +822,19 @@ export class Session {
/**
* Handle create agent request
*/
private async handleCreateAgentRequest(
cwd: string,
initialMode?: string,
requestId?: string,
worktreeName?: string
): Promise<void> {
private async handleCreateAgentRequest({
cwd,
initialMode,
requestId,
worktreeName,
agentType,
}: {
cwd: string;
initialMode?: string;
requestId?: string;
worktreeName?: string;
agentType?: AgentType;
}): Promise<void> {
console.log(
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${
initialMode || "default"
@@ -859,8 +868,11 @@ export class Session {
);
}
const normalizedType: AgentType = agentType ?? "claude";
const agentId = await this.agentManager.createAgent({
cwd: effectiveCwd,
type: normalizedType,
initialMode,
});
@@ -892,7 +904,7 @@ export class Session {
payload: {
agentId,
status: info.status,
type: "claude",
type: info.type,
currentModeId: info.currentModeId ?? undefined,
availableModes: info.availableModes ?? undefined,
title: info.title ?? undefined,
@@ -1419,7 +1431,7 @@ export class Session {
payload: {
agentId,
status: agentInfo.status,
type: "claude",
type: agentInfo.type,
currentModeId: agentInfo.currentModeId ?? undefined,
availableModes: agentInfo.availableModes ?? undefined,
title: agentInfo.title || undefined,