diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx index bbf6d1f00..e2da089f5 100644 --- a/packages/app/src/components/agent-activity.tsx +++ b/packages/app/src/components/agent-activity.tsx @@ -61,7 +61,7 @@ function GroupedTextItem({ item }: { item: GroupedTextMessage }) { return ( - + {formatTimestamp(item.startTimestamp)} {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, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 6c8ac90e2..7ae78886b 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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; 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 ( - ); @@ -235,6 +261,10 @@ export function AgentStreamView({ }} style={stylesheet.list} onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEnd} + onMomentumScrollBegin={handleMomentumScrollBegin} + onMomentumScrollEnd={handleScrollEnd} scrollEventThrottle={16} ListEmptyComponent={ diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index e49ad8834..0ec7ed6f4 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -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 = + {} as Record; +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( + 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(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(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({ )} + {/* Agent Type Selector */} + + Agent Type + + {agentTypeDefinitions.map((definition) => { + const isSelected = selectedAgentType === definition.id; + return ( + setSelectedAgentType(definition.id)} + disabled={isLoading} + style={[ + styles.agentTypeOption, + isSelected && styles.agentTypeOptionSelected, + isLoading && styles.agentTypeOptionDisabled, + ]} + > + + + {isSelected && } + + + + {definition.label} + + {definition.description ? ( + + {definition.description} + + ) : null} + + {definition.availableModes.length > 0 + ? `Modes: ${definition.availableModes + .map((mode) => mode.name) + .join(", ")}` + : "Modes: none"} + + + + + ); + })} + + + {/* Worktree Name Input (Optional) */} Worktree Name (Optional) @@ -364,41 +467,50 @@ export function CreateAgentModal({ {/* Mode Selector */} Mode - - {MODES.map((mode) => ( - setSelectedMode(mode.value)} - disabled={isLoading} - style={[ - styles.modeOption, - selectedMode === mode.value && styles.modeOptionSelected, - isLoading && styles.modeOptionDisabled, - ]} - > - - + This agent type does not expose selectable modes. + + ) : ( + + {modeOptions.map((mode) => { + const isSelected = selectedMode === mode.id; + return ( + 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 && ( - - )} - - - {mode.label} - - {mode.description} - - - - - ))} - + + + {isSelected && } + + + {mode.name} + {mode.description ? ( + + {mode.description} + + ) : null} + + + + ); + })} + + )} @@ -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, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 24f63f74f..24dc066c8 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( @@ -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 ( {message} @@ -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 ( + + + + + + + {messageText} + + + + ); +} + interface ToolCallProps { toolName: string; kind?: string; // Optional kind for ACP tool calls diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index a7af862ac..0a765f84c 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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; sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise; - 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: { diff --git a/packages/app/src/server/agents/agent-type-definitions.ts b/packages/app/src/server/agents/agent-type-definitions.ts deleted file mode 100644 index f2e7340b9..000000000 --- a/packages/app/src/server/agents/agent-type-definitions.ts +++ /dev/null @@ -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 = { - 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; -} diff --git a/packages/app/src/types/shared.ts b/packages/app/src/types/shared.ts index 4d5895c53..7665ae832 100644 --- a/packages/app/src/types/shared.ts +++ b/packages/app/src/types/shared.ts @@ -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'; -} \ No newline at end of file +// Agent info interface is provided by SessionContext's Agent type diff --git a/packages/server/agents.json b/packages/server/agents.json index 99430e83f..76d15eb7c 100644 --- a/packages/server/agents.json +++ b/packages/server/agents.json @@ -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" } ] \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index 612144ef2..641746058 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/acp/agent-manager.test.ts b/packages/server/src/server/acp/agent-manager.test.ts index 0c9daa4f5..fb6d0e31d 100644 --- a/packages/server/src/server/acp/agent-manager.test.ts +++ b/packages/server/src/server/acp/agent-manager.test.ts @@ -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 }; } diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts index 7a39b7fb5..bbd0bcfea 100644 --- a/packages/server/src/server/acp/agent-manager.ts +++ b/packages/server/src/server/acp/agent-manager.ts @@ -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> | Awaited>; 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) => { diff --git a/packages/server/src/server/acp/agent-persistence.ts b/packages/server/src/server/acp/agent-persistence.ts index a4b1220ff..4bcba02f6 100644 --- a/packages/server/src/server/acp/agent-persistence.ts +++ b/packages/server/src/server/acp/agent-persistence.ts @@ -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; diff --git a/packages/server/src/server/acp/agent-types.ts b/packages/server/src/server/acp/agent-types.ts new file mode 100644 index 000000000..00312eaf2 --- /dev/null +++ b/packages/server/src/server/acp/agent-types.ts @@ -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 = { + 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); +} diff --git a/packages/server/src/server/acp/mcp-server.ts b/packages/server/src/server/acp/mcp-server.ts index ed044f1e7..8acf1c889 100644 --- a/packages/server/src/server/acp/mcp-server.ts +++ b/packages/server/src/server/acp/mcp-server.ts @@ -121,6 +121,7 @@ export async function createAgentMcpServer( const agentId = await agentManager.createAgent({ cwd: resolvedCwd, + type: "claude", initialPrompt, initialMode, }); diff --git a/packages/server/src/server/acp/test-agent.ts b/packages/server/src/server/acp/test-agent.ts deleted file mode 100644 index 4903489d0..000000000 --- a/packages/server/src/server/acp/test-agent.ts +++ /dev/null @@ -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((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); -}); diff --git a/packages/server/src/server/acp/test-comprehensive.ts b/packages/server/src/server/acp/test-comprehensive.ts deleted file mode 100755 index d3857f64a..000000000 --- a/packages/server/src/server/acp/test-comprehensive.ts +++ /dev/null @@ -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 { - 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 { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// Run the test suite -main().catch((error) => { - console.error("Fatal error:", error); - process.exit(1); -}); diff --git a/packages/server/src/server/acp/test-concurrent.ts b/packages/server/src/server/acp/test-concurrent.ts deleted file mode 100644 index 3bda9146f..000000000 --- a/packages/server/src/server/acp/test-concurrent.ts +++ /dev/null @@ -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); -}); diff --git a/packages/server/src/server/acp/test-integration.ts b/packages/server/src/server/acp/test-integration.ts deleted file mode 100755 index 87110423f..000000000 --- a/packages/server/src/server/acp/test-integration.ts +++ /dev/null @@ -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 { - 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); -}); diff --git a/packages/server/src/server/acp/test-simple.ts b/packages/server/src/server/acp/test-simple.ts deleted file mode 100644 index 03352930f..000000000 --- a/packages/server/src/server/acp/test-simple.ts +++ /dev/null @@ -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); -}); diff --git a/packages/server/src/server/acp/types.ts b/packages/server/src/server/acp/types.ts index 9a8905c65..1def2fb37 100644 --- a/packages/server/src/server/acp/types.ts +++ b/packages/server/src/server/acp/types.ts @@ -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; } diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 82d5950f1..6c8afddbf 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -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(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3fa5b61b4..938464277 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + private async handleCreateAgentRequest({ + cwd, + initialMode, + requestId, + worktreeName, + agentType, + }: { + cwd: string; + initialMode?: string; + requestId?: string; + worktreeName?: string; + agentType?: AgentType; + }): Promise { 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,