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

View File

@@ -61,7 +61,7 @@ function GroupedTextItem({ item }: { item: GroupedTextMessage }) {
return (
<View style={[stylesheet.card, isThought && stylesheet.thoughtCard]}>
<Text style={stylesheet.timestamp}>
<Text style={[stylesheet.timestamp, isThought && stylesheet.thoughtTimestamp]}>
{formatTimestamp(item.startTimestamp)}
</Text>
{isThought && (
@@ -260,17 +260,21 @@ const stylesheet = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.muted,
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
paddingVertical: theme.spacing[2],
},
timestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
thoughtTimestamp: {
marginBottom: theme.spacing[0],
},
thoughtLabel: {
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
marginBottom: theme.spacing[0],
},
text: {
color: theme.colors.foreground,

View File

@@ -19,18 +19,16 @@ import {
UserMessage,
ActivityLog,
ToolCall,
AgentThoughtMessage,
} from "./message";
import { ToolCallBottomSheet } from "./tool-call-bottom-sheet";
import type { StreamItem } from "@/types/stream";
import type {
SelectedToolCall,
PendingPermission,
AgentInfo,
} from "@/types/shared";
import type { SelectedToolCall, PendingPermission } from "@/types/shared";
import type { Agent } from "@/contexts/session-context";
export interface AgentStreamViewProps {
agentId: string;
agent: AgentInfo;
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
onPermissionResponse: (requestId: string, optionId: string) => void;
@@ -53,6 +51,7 @@ export function AgentStreamView({
const hasAutoScrolledOnce = useRef(false);
const isProgrammaticScrollRef = useRef(false);
const isNearBottomRef = useRef(true);
const isUserScrollingRef = useRef(false);
useEffect(() => {
hasScrolledInitially.current = false;
@@ -77,12 +76,41 @@ export function AgentStreamView({
const { contentOffset } = event.nativeEvent;
const threshold = Math.max(insets.bottom, 32);
const nearBottom = contentOffset.y <= threshold;
if (isProgrammaticScrollRef.current) {
if (nearBottom && !isNearBottomRef.current) {
isNearBottomRef.current = true;
setIsNearBottom(true);
}
return;
}
if (!nearBottom && !isUserScrollingRef.current) {
return;
}
if (isNearBottomRef.current === nearBottom) {
return;
}
isNearBottomRef.current = nearBottom;
setIsNearBottom(nearBottom);
},
[insets.bottom]
);
const handleScrollBeginDrag = useCallback(() => {
isUserScrollingRef.current = true;
}, []);
const handleMomentumScrollBegin = useCallback(() => {
isUserScrollingRef.current = true;
}, []);
const handleScrollEnd = useCallback(() => {
isUserScrollingRef.current = false;
}, []);
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const list = flatListRef.current;
@@ -156,10 +184,8 @@ export function AgentStreamView({
case "thought":
return (
<ActivityLog
type="info"
<AgentThoughtMessage
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
@@ -235,6 +261,10 @@ export function AgentStreamView({
}}
style={stylesheet.list}
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
onScrollEndDrag={handleScrollEnd}
onMomentumScrollBegin={handleMomentumScrollBegin}
onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={stylesheet.invertedWrapper}>

View File

@@ -27,24 +27,30 @@ import { useRecentPaths } from "@/hooks/use-recent-paths";
import { useSession } from "@/contexts/session-context";
import { useRouter } from "expo-router";
import { generateMessageId } from "@/types/stream";
import {
listAgentTypeDefinitions,
type AgentType,
type AgentTypeDefinition,
} from "@server/server/acp/agent-types";
interface CreateAgentModalProps {
isVisible: boolean;
onClose: () => void;
}
const MODES = [
{
value: "plan",
label: "Plan",
description: "Plan and design before implementing",
},
{
value: "bypassPermissions",
label: "Bypass Permissions",
description: "Skip permission prompts for faster execution",
},
] as const;
const agentTypeDefinitions = listAgentTypeDefinitions();
const agentTypeDefinitionMap: Record<AgentType, AgentTypeDefinition> =
{} as Record<AgentType, AgentTypeDefinition>;
for (const definition of agentTypeDefinitions) {
agentTypeDefinitionMap[definition.id] = definition;
}
const fallbackDefinition = agentTypeDefinitionMap.claude ?? agentTypeDefinitions[0];
const DEFAULT_AGENT_TYPE: AgentType = fallbackDefinition
? fallbackDefinition.id
: "claude";
const DEFAULT_MODE_FOR_DEFAULT_AGENT = fallbackDefinition?.defaultModeId;
export function CreateAgentModal({
isVisible,
@@ -58,11 +64,41 @@ export function CreateAgentModal({
const router = useRouter();
const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan");
const [selectedAgentType, setSelectedAgentType] = useState<AgentType>(
DEFAULT_AGENT_TYPE
);
const [selectedMode, setSelectedMode] = useState(
DEFAULT_MODE_FOR_DEFAULT_AGENT ?? ""
);
const [worktreeName, setWorktreeName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
const agentDefinition = agentTypeDefinitionMap[selectedAgentType];
const modeOptions = agentDefinition?.availableModes ?? [];
useEffect(() => {
if (!agentDefinition) {
return;
}
const availableModeIds = agentDefinition.availableModes.map(
(mode) => mode.id
);
if (availableModeIds.length === 0) {
if (selectedMode !== "") {
setSelectedMode("");
}
return;
}
if (!availableModeIds.includes(selectedMode)) {
const fallbackModeId =
agentDefinition.defaultModeId ?? availableModeIds[0];
setSelectedMode(fallbackModeId);
}
}, [agentDefinition, selectedMode]);
// Use ref instead of state to survive state resets in onDismiss
const pendingNavigationAgentIdRef = useRef<string | null>(null);
@@ -164,11 +200,15 @@ export function CreateAgentModal({
setPendingRequestId(requestId);
setErrorMessage("");
const modeId =
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
// Create the agent
try {
createAgent({
cwd: path,
initialMode: selectedMode,
agentType: selectedAgentType,
initialMode: modeId,
worktreeName: worktree || undefined,
requestId,
});
@@ -178,7 +218,17 @@ export function CreateAgentModal({
setIsLoading(false);
setPendingRequestId(null);
}
}, [workingDir, worktreeName, selectedMode, isLoading, validateWorktreeName, addRecentPath, createAgent]);
}, [
workingDir,
worktreeName,
selectedMode,
selectedAgentType,
modeOptions,
isLoading,
validateWorktreeName,
addRecentPath,
createAgent,
]);
const renderFooter = useCallback(
(props: BottomSheetFooterProps) => (
@@ -254,7 +304,8 @@ export function CreateAgentModal({
// Reset all state
setWorkingDir("");
setWorktreeName("");
setSelectedMode("plan");
setSelectedAgentType(DEFAULT_AGENT_TYPE);
setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_AGENT ?? "");
setErrorMessage("");
setIsLoading(false);
setPendingRequestId(null);
@@ -338,6 +389,58 @@ export function CreateAgentModal({
)}
</View>
{/* Agent Type Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Agent Type</Text>
<View style={styles.agentTypeContainer}>
{agentTypeDefinitions.map((definition) => {
const isSelected = selectedAgentType === definition.id;
return (
<Pressable
key={definition.id}
onPress={() => setSelectedAgentType(definition.id)}
disabled={isLoading}
style={[
styles.agentTypeOption,
isSelected && styles.agentTypeOptionSelected,
isLoading && styles.agentTypeOptionDisabled,
]}
>
<View style={styles.agentTypeOptionContent}>
<View
style={[
styles.radioOuter,
isSelected
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.agentTypeTextContainer}>
<Text style={styles.agentTypeLabel}>
{definition.label}
</Text>
{definition.description ? (
<Text style={styles.agentTypeDescription}>
{definition.description}
</Text>
) : null}
<Text style={styles.agentTypeMeta}>
{definition.availableModes.length > 0
? `Modes: ${definition.availableModes
.map((mode) => mode.name)
.join(", ")}`
: "Modes: none"}
</Text>
</View>
</View>
</Pressable>
);
})}
</View>
</View>
{/* Worktree Name Input (Optional) */}
<View style={styles.formSection}>
<Text style={styles.label}>Worktree Name (Optional)</Text>
@@ -364,41 +467,50 @@ export function CreateAgentModal({
{/* Mode Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Mode</Text>
<View style={styles.modeContainer}>
{MODES.map((mode) => (
<Pressable
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
disabled={isLoading}
style={[
styles.modeOption,
selectedMode === mode.value && styles.modeOptionSelected,
isLoading && styles.modeOptionDisabled,
]}
>
<View style={styles.modeOptionContent}>
<View
{modeOptions.length === 0 ? (
<Text style={styles.helperText}>
This agent type does not expose selectable modes.
</Text>
) : (
<View style={styles.modeContainer}>
{modeOptions.map((mode) => {
const isSelected = selectedMode === mode.id;
return (
<Pressable
key={mode.id}
onPress={() => setSelectedMode(mode.id)}
disabled={isLoading}
style={[
styles.radioOuter,
selectedMode === mode.value
? styles.radioOuterSelected
: styles.radioOuterUnselected,
styles.modeOption,
isSelected && styles.modeOptionSelected,
isLoading && styles.modeOptionDisabled,
]}
>
{selectedMode === mode.value && (
<View style={styles.radioInner} />
)}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.label}</Text>
<Text style={styles.modeDescription}>
{mode.description}
</Text>
</View>
</View>
</Pressable>
))}
</View>
<View style={styles.modeOptionContent}>
<View
style={[
styles.radioOuter,
isSelected
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.name}</Text>
{mode.description ? (
<Text style={styles.modeDescription}>
{mode.description}
</Text>
) : null}
</View>
</View>
</Pressable>
);
})}
</View>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
@@ -516,6 +628,45 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
agentTypeContainer: {
gap: theme.spacing[3],
},
agentTypeOption: {
backgroundColor: theme.colors.background,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
padding: theme.spacing[4],
},
agentTypeOptionSelected: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
agentTypeOptionDisabled: {
opacity: theme.opacity[50],
},
agentTypeOptionContent: {
flexDirection: "row",
alignItems: "center",
},
agentTypeTextContainer: {
flex: 1,
},
agentTypeLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
agentTypeDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[1],
},
agentTypeMeta: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,

View File

@@ -1,5 +1,5 @@
import { View, Text, Pressable, Animated } from "react-native";
import { useState, useEffect, useRef, memo } from "react";
import { useState, useEffect, useRef, memo, useMemo } from "react";
import Markdown from "react-native-markdown-display";
import {
Circle,
@@ -16,6 +16,7 @@ import {
Pencil,
Eye,
SquareTerminal,
Brain,
} from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
@@ -48,7 +49,10 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
export const UserMessage = memo(function UserMessage({ message, timestamp }: UserMessageProps) {
export const UserMessage = memo(function UserMessage({
message,
timestamp,
}: UserMessageProps) {
return (
<View style={userMessageStylesheet.container}>
<View style={userMessageStylesheet.bubble}>
@@ -64,7 +68,7 @@ interface AssistantMessageProps {
isStreaming?: boolean;
}
const assistantMessageStylesheet = StyleSheet.create((theme) => ({
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
container: {
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
@@ -133,6 +137,20 @@ const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
}));
const markdownStyles = {
body: assistantMessageStylesheet.markdownBody,
paragraph: assistantMessageStylesheet.markdownParagraph,
strong: assistantMessageStylesheet.markdownStrong,
em: assistantMessageStylesheet.markdownEm,
code_inline: assistantMessageStylesheet.markdownCodeInline,
code_block: assistantMessageStylesheet.markdownCodeBlock,
fence: assistantMessageStylesheet.markdownFence,
link: assistantMessageStylesheet.markdownLink,
bullet_list: assistantMessageStylesheet.markdownList,
ordered_list: assistantMessageStylesheet.markdownList,
list_item: assistantMessageStylesheet.markdownListItem,
};
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp,
@@ -162,20 +180,6 @@ export const AssistantMessage = memo(function AssistantMessage({
}
}, [isStreaming, fadeAnim]);
const markdownStyles = {
body: assistantMessageStylesheet.markdownBody,
paragraph: assistantMessageStylesheet.markdownParagraph,
strong: assistantMessageStylesheet.markdownStrong,
em: assistantMessageStylesheet.markdownEm,
code_inline: assistantMessageStylesheet.markdownCodeInline,
code_block: assistantMessageStylesheet.markdownCodeBlock,
fence: assistantMessageStylesheet.markdownFence,
link: assistantMessageStylesheet.markdownLink,
bullet_list: assistantMessageStylesheet.markdownList,
ordered_list: assistantMessageStylesheet.markdownList,
list_item: assistantMessageStylesheet.markdownListItem,
};
return (
<View style={assistantMessageStylesheet.container}>
<Markdown style={markdownStyles}>{message}</Markdown>
@@ -376,6 +380,79 @@ export const ActivityLog = memo(function ActivityLog({
);
});
interface AgentThoughtMessageProps {
message: string;
}
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
container: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
},
card: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
iconContainer: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
paddingTop: theme.spacing[1],
},
icon: {
color: theme.colors.mutedForeground,
opacity: 0.8,
},
textContainer: {
flex: 1,
paddingVertical: theme.spacing[1],
},
label: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
marginBottom: theme.spacing[1],
},
text: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
lineHeight: 20,
},
}));
export function AgentThoughtMessage({ message }: AgentThoughtMessageProps) {
const messageText = useMemo(() => {
return message
.trim()
.replace(/^\*\*|\*\*$/g, "")
.trim();
}, [message]);
return (
<View style={agentThoughtStylesheet.container}>
<View style={agentThoughtStylesheet.card}>
<View style={agentThoughtStylesheet.iconContainer}>
<Brain size={18} style={agentThoughtStylesheet.icon} />
</View>
<View style={agentThoughtStylesheet.textContainer}>
<Text style={agentThoughtStylesheet.text}>{messageText}</Text>
</View>
</View>
</View>
);
}
interface ToolCallProps {
toolName: string;
kind?: string; // Optional kind for ACP tool calls

View File

@@ -9,6 +9,7 @@ import type {
WSInboundMessage,
} from "@server/server/messages";
import type { AgentStatus, AgentUpdate, AgentNotification } from "@server/server/acp/types";
import type { AgentType } from "@server/server/acp/agent-types";
import { parseSessionUpdate } from "@/types/agent-activity";
import { ScrollView } from "react-native";
import * as FileSystem from 'expo-file-system';
@@ -58,7 +59,7 @@ export interface Agent {
status: AgentStatus;
createdAt: Date;
lastActivityAt: Date;
type: "claude";
type: AgentType;
sessionId: string | null;
error: string | null;
currentModeId: string | null;
@@ -118,7 +119,13 @@ interface SessionContextValue {
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
createAgent: (options: { cwd: string; initialMode?: string; worktreeName?: string; requestId?: string }) => void;
createAgent: (options: {
cwd: string;
agentType: AgentType;
initialMode?: string;
worktreeName?: string;
requestId?: string;
}) => void;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void;
}
@@ -720,7 +727,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
}
}, [ws]);
const createAgent = useCallback((options: { cwd: string; initialMode?: string; worktreeName?: string; requestId?: string }) => {
const createAgent = useCallback((options: { cwd: string; agentType: AgentType; initialMode?: string; worktreeName?: string; requestId?: string }) => {
const msg: WSInboundMessage = {
type: "session",
message: {

View File

@@ -1,69 +0,0 @@
export type AgentType = "claude" | "codex";
export interface AgentModeDefinition {
id: string;
name: string;
description?: string;
}
export interface AgentTypeDefinition {
id: AgentType;
label: string;
spawn: {
command: string;
args: string[];
};
supportsSessionPersistence: boolean;
availableModes: AgentModeDefinition[];
defaultModeId: string;
}
export const agentTypeDefinitions: Record<AgentType, AgentTypeDefinition> = {
claude: {
id: "claude",
label: "Claude Code",
spawn: {
command: "npx",
args: ["@boudra/claude-code-acp"],
},
supportsSessionPersistence: true,
availableModes: [
{
id: "plan",
name: "Plan",
description: "Plan and design before implementing",
},
{
id: "bypassPermissions",
name: "Bypass Permissions",
description: "Skip permission prompts for faster execution",
},
],
defaultModeId: "plan",
},
codex: {
id: "codex",
label: "Codex",
spawn: {
command: "npx",
args: ["@zed-industries/codex-acp"],
},
supportsSessionPersistence: false,
availableModes: [
{
id: "default",
name: "Default",
description: "Standard coding mode",
},
],
defaultModeId: "default",
},
} as const;
export function getAgentTypeDefinition(type: AgentType): AgentTypeDefinition {
return agentTypeDefinitions[type];
}
export function getAgentModes(type: AgentType): AgentModeDefinition[] {
return agentTypeDefinitions[type].availableModes;
}

View File

@@ -1,4 +1,3 @@
import type { AgentStatus } from '@server/server/acp/types';
import type { RequestPermissionRequest } from '@agentclientprotocol/sdk';
import type { ToolCallPayload } from './stream';
@@ -22,12 +21,4 @@ export interface PendingPermission {
options: RequestPermissionRequest['options'];
}
/**
* Agent info for UI display
*/
export interface AgentInfo {
id: string;
status: AgentStatus;
createdAt: Date;
type: 'claude';
}
// Agent info interface is provided by SessionContext's Agent type

View File

@@ -284,7 +284,7 @@
"sessionId": "20b0b2c2-18ec-409b-b932-a20c9ca8e0d3"
},
"createdAt": "2025-10-26T09:45:29.563Z",
"lastActivityAt": "2025-10-26T13:11:22.795Z",
"lastActivityAt": "2025-10-26T14:53:00.404Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
@@ -380,7 +380,18 @@
"sessionId": "addbd42f-dbb1-4536-bc2b-e70a64587de6"
},
"createdAt": "2025-10-26T14:02:59.844Z",
"lastActivityAt": "2025-10-26T14:08:58.462Z",
"lastActivityAt": "2025-10-26T16:45:15.700Z",
"cwd": "/Users/moboudra/dev/voice-dev"
},
{
"id": "f1df26aa-4b20-499e-a84b-1d61828cf7fe",
"title": "Prepare Concise Session Title",
"sessionId": "019a215a-22dd-7930-9db7-e38d256ac597",
"options": {
"type": "codex"
},
"createdAt": "2025-10-26T16:29:01.354Z",
"lastActivityAt": "2025-10-26T16:29:17.523Z",
"cwd": "/Users/moboudra/dev/voice-dev"
}
]

View File

@@ -20,6 +20,7 @@
"@agentclientprotocol/sdk": "^0.4.9",
"@ai-sdk/openai": "^2.0.52",
"@boudra/claude-code-acp": "^0.8.5",
"@zed-industries/codex-acp": "^0.3.9",
"@deepgram/sdk": "^3.4.0",
"@modelcontextprotocol/sdk": "^1.20.1",
"@openrouter/ai-sdk-provider": "^1.2.0",

View File

@@ -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 };
}

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) => {

View File

@@ -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>;

View File

@@ -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);
}

View File

@@ -121,6 +121,7 @@ export async function createAgentMcpServer(
const agentId = await agentManager.createAgent({
cwd: resolvedCwd,
type: "claude",
initialPrompt,
initialMode,
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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);
});

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;
}

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(),

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,