From ebabcc09e58283a398c8ef64fdb1301db96f432c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 11 Jan 2026 16:00:25 +0700 Subject: [PATCH] feat: add opencode provider and refactor stream model to head/tail architecture --- .tasks/1a4d69cf.md | 9 + .tasks/4672647b.md | 9 + .tasks/90e02e62.md | 332 ++++ .tasks/9b83b90a.md | 92 + .tasks/b694a5c0.md | 9 + .tasks/d0dc7bd2.md | 9 + .tasks/ea3f3266.md | 9 + package-lock.json | 17 +- package.json | 3 +- .../src/app/agent/[serverId]/[agentId].tsx | 4 +- packages/app/src/app/index.tsx | 217 +-- .../agent-form/agent-form-dropdowns.tsx | 336 ++++ .../app/src/components/agent-stream-view.tsx | 265 ++- .../app/src/components/create-agent-modal.tsx | 1464 ---------------- packages/app/src/components/git-diff-pane.tsx | 86 +- packages/app/src/components/message.tsx | 165 +- packages/app/src/contexts/session-context.tsx | 1502 ++++++++++------- .../app/src/hooks/use-agent-form-state.ts | 2 + packages/app/src/stores/session-store.ts | 59 +- packages/app/src/types/stream-buffer.test.ts | 114 +- packages/app/src/types/stream.ts | 382 +++-- packages/server/package.json | 1 + .../src/server/agent/agent-sdk-types.ts | 7 +- .../server/src/server/agent/mcp-server.ts | 2 +- .../server/agent/model-catalog.e2e.test.ts | 60 +- .../server/src/server/agent/model-catalog.ts | 330 ---- .../src/server/agent/model-resolver.test.ts | 8 +- .../server/src/server/agent/model-resolver.ts | 4 +- .../agent/opencode-reasoning.e2e.test.ts | 140 ++ .../src/server/agent/provider-manifest.ts | 31 +- .../src/server/agent/provider-registry.ts | 80 + .../agent/providers/claude-agent.test.ts | 37 + .../server/agent/providers/claude-agent.ts | 34 + .../agent/providers/codex-mcp-agent.test.ts | 34 + .../server/agent/providers/codex-mcp-agent.ts | 227 ++- .../agent/providers/opencode-agent.test.ts | 426 +++++ .../server/agent/providers/opencode-agent.ts | 798 +++++++++ packages/server/src/server/bootstrap.ts | 6 +- packages/server/src/server/messages.ts | 5 +- packages/server/src/server/session.ts | 19 +- .../src/server/test-utils/daemon-client.ts | 2 +- .../src/server/test-utils/paseo-daemon.ts | 6 +- 42 files changed, 4416 insertions(+), 2926 deletions(-) create mode 100644 .tasks/1a4d69cf.md create mode 100644 .tasks/4672647b.md create mode 100644 .tasks/90e02e62.md create mode 100644 .tasks/9b83b90a.md create mode 100644 .tasks/b694a5c0.md create mode 100644 .tasks/d0dc7bd2.md create mode 100644 .tasks/ea3f3266.md delete mode 100644 packages/app/src/components/create-agent-modal.tsx delete mode 100644 packages/server/src/server/agent/model-catalog.ts create mode 100644 packages/server/src/server/agent/opencode-reasoning.e2e.test.ts create mode 100644 packages/server/src/server/agent/provider-registry.ts create mode 100644 packages/server/src/server/agent/providers/opencode-agent.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode-agent.ts diff --git a/.tasks/1a4d69cf.md b/.tasks/1a4d69cf.md new file mode 100644 index 000000000..83982e301 --- /dev/null +++ b/.tasks/1a4d69cf.md @@ -0,0 +1,9 @@ +--- +id: 1a4d69cf +title: OpenCode: Interruption test +status: done +deps: [d0dc7bd2] +created: 2026-01-11T04:30:18.687Z +parentId: 90e02e62 +--- + diff --git a/.tasks/4672647b.md b/.tasks/4672647b.md new file mode 100644 index 000000000..2bd56baca --- /dev/null +++ b/.tasks/4672647b.md @@ -0,0 +1,9 @@ +--- +id: 4672647b +title: OpenCode: Prompting and text response test +status: done +deps: [d0dc7bd2] +created: 2026-01-11T04:30:17.444Z +parentId: 90e02e62 +--- + diff --git a/.tasks/90e02e62.md b/.tasks/90e02e62.md new file mode 100644 index 000000000..2c530cd9a --- /dev/null +++ b/.tasks/90e02e62.md @@ -0,0 +1,332 @@ +--- +id: 90e02e62 +title: Add OpenCode agent provider with feature parity to Claude/Codex +status: open +deps: [] +created: 2026-01-11T04:14:38.396Z +--- + +# OpenCode Agent Provider Integration + +## Overview + +Add OpenCode as a third agent provider in Paseo alongside Claude and Codex. OpenCode is an open-source AI coding assistant by SST that supports multiple LLM providers (Anthropic, OpenAI, etc.) through user configuration. + +## Repository & Architecture + +- **Repository**: https://github.com/sst/opencode +- **Packages**: + - `packages/opencode` - Core TypeScript/Bun library (CLI + server) + - `packages/sdk` - JavaScript SDK for programmatic access (`@opencode/sdk`) + +### Server Architecture + +OpenCode uses a **server-based architecture**: +- HTTP server with SSE for events (Hono framework on Bun) +- One server can handle **multiple directories** via \`x-opencode-directory\` header +- One server can handle **multiple concurrent sessions** in the same directory +- Storage is global at \`~/.local/share/opencode/\` (XDG data dir) +- Sessions stored at: \`session/{projectID}/{sessionID}.json\` where projectID = git root commit hash + +### Key Server Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| \`/session\` | POST | Create new session | +| \`/session/:id/message\` | POST | Send prompt (blocking) | +| \`/session/:id/prompt_async\` | POST | Send prompt (non-blocking) | +| \`/session/:id/abort\` | POST | Cancel/interrupt session | +| \`/event\` | GET (SSE) | Subscribe to all events | +| \`/permission/:requestID/reply\` | POST | Respond to permission request | +| \`/config\` | GET | Get configuration | +| \`/instance/dispose\` | POST | Dispose instance (for config reload) | + +### Directory Routing + +Every API request specifies its working directory: +\`\`\`typescript +// Via header +fetch('/session', { headers: { 'x-opencode-directory': '/path/to/project' } }) + +// Via query param +fetch('/session?directory=/path/to/project') +\`\`\` + +The server maintains per-directory "Instance" contexts that are lazily created and cached. + +### Port Selection + +When starting with \`--port 0\`: +1. Tries port 4096 first +2. Falls back to random port if 4096 is busy +3. Returns actual port via server URL + +For Paseo: We should **choose our own random port** to avoid race conditions and have predictable control. + +## SDK Usage + +The JS SDK (\`@opencode/sdk\`) provides typed client: + +\`\`\`typescript +import { Server } from "@opencode/sdk/v2" + +const client = new Server("http://localhost:PORT") + +// Create session +const session = await client.session.create({ + directory: "/path/to/project" +}, { + headers: { 'x-opencode-directory': '/path/to/project' } +}) + +// Subscribe to events (SSE) +for await (const event of client.event.subscribe()) { + // Handle events +} + +// Send prompt +await client.session.prompt_async({ + sessionID: session.id, + parts: [{ type: "text", text: "Your prompt" }], + providerID: "anthropic", + modelID: "claude-sonnet-4-20250514", + agent: "coder" +}) + +// Respond to permission +await client.permission.reply({ + requestID: "...", + reply: "allow" // or "deny", "always", "session" +}) +\`\`\` + +## Event Types + +Events come via SSE at \`/event\`. Key event types: + +- \`session.created\` / \`session.updated\` / \`session.deleted\` +- \`message.updated\` - Message content updates +- \`message.part.updated\` - Streaming part updates (text, tool calls, reasoning) +- \`permission.request\` - Permission request from agent +- \`session.error\` - Error events + +### Message Part Types + +\`\`\`typescript +type Part = + | { type: "text"; content: string } + | { type: "reasoning"; content: string } + | { type: "tool-invocation"; toolName: string; input: object; state: "pending" | "running" | "complete" | "error"; output?: object } +\`\`\` + +## Permission System + +OpenCode has a permission system similar to Claude/Codex: + +### Permission Request +\`\`\`typescript +{ + id: string + sessionID: string + tool: string // e.g., "bash", "write", "edit" + input: object // tool input parameters + metadata?: object +} +\`\`\` + +### Permission Reply Options +- \`"allow"\` - Allow this specific request +- \`"deny"\` - Deny this specific request +- \`"always"\` - Always allow this tool +- \`"session"\` - Allow for this session + +### Mapping to Paseo Types + +| Paseo | OpenCode | +|-------|----------| +| \`AgentPermissionRequest.id\` | \`request.id\` | +| \`AgentPermissionRequest.name\` | \`request.tool\` | +| \`AgentPermissionRequest.kind\` | Derive from tool name | +| \`AgentPermissionRequest.input\` | \`request.input\` | +| \`AgentPermissionResponse.behavior: "allow"\` | \`reply: "allow"\` | +| \`AgentPermissionResponse.behavior: "deny"\` | \`reply: "deny"\` | + +## Config & Model Selection + +Users configure OpenCode in \`~/.config/opencode/opencode.json\` or project \`opencode.json\`: + +\`\`\`json +{ + "model": "anthropic/claude-sonnet-4-20250514", + "provider": { + "anthropic": {}, + "openai": { "apiKey": "..." } + } +} +\`\`\` + +**For Paseo**: +- Default to user's configured model +- Can override per-prompt via \`providerID\` + \`modelID\` in API call + +### Config Reload + +To reload config without restarting server: +1. Send \`SIGUSR2\` to server process, OR +2. \`POST /instance/dispose?directory=/path\` to dispose specific instance + +## Implementation Requirements + +### 1. Server Lifecycle Management + +Create lazy singleton server manager: +\`\`\`typescript +class OpenCodeServerManager { + private server: ChildProcess | null = null + private port: number | null = null + + async ensureRunning(): Promise<{ port: number; url: string }> { + if (this.server && this.isHealthy()) { + return { port: this.port!, url: \`http://localhost:\${this.port}\` } + } + + // Choose random available port + this.port = await getRandomAvailablePort() + + // Launch: opencode serve --port + this.server = spawn('opencode', ['serve', '--port', String(this.port)]) + + // Wait for health check + await this.waitForHealth() + + return { port: this.port, url: \`http://localhost:\${this.port}\` } + } + + async shutdown(): Promise { + // Graceful shutdown + } +} +\`\`\` + +### 2. Agent Types Update + +\`\`\`typescript +// agent-sdk-types.ts +export type AgentProvider = "codex" | "claude" | "opencode" +\`\`\` + +### 3. OpenCodeAgentClient Implementation + +Implement \`AgentClient\` interface: +- \`createSession(config)\` - Create new session via POST /session +- \`resumeSession(handle)\` - Resume existing session +- \`listPersistedAgents()\` - List sessions via GET /session + +### 4. OpenCodeAgentSession Implementation + +Implement \`AgentSession\` interface: + +| Method | OpenCode Implementation | +|--------|------------------------| +| \`stream(prompt)\` | POST /session/:id/prompt_async + SSE /event | +| \`run(prompt)\` | POST /session/:id/message (blocking) | +| \`interrupt()\` | POST /session/:id/abort | +| \`respondToPermission()\` | POST /permission/:id/reply | +| \`getAvailableModes()\` | Return static modes or from config | +| \`setMode()\` | Update session permission ruleset | +| \`getRuntimeInfo()\` | Return provider/model from session | + +### 5. Event Translation + +Map OpenCode events to Paseo \`AgentStreamEvent\`: + +| OpenCode Event | Paseo Event | +|----------------|-------------| +| \`session.created\` | \`thread_started\` | +| \`message.part.updated\` (text) | \`timeline\` with \`assistant_message\` | +| \`message.part.updated\` (reasoning) | \`timeline\` with \`reasoning\` | +| \`message.part.updated\` (tool-invocation) | \`timeline\` with \`tool_call\` | +| \`permission.request\` | \`permission_requested\` | +| \`session.error\` | \`turn_failed\` | + +### 6. Tool Call Mapping + +Map OpenCode tool names to Paseo \`ToolCallKind\`: + +| Tool Name Pattern | Kind | +|-------------------|------| +| \`read\`, \`glob\`, \`grep\` | \`read\` | +| \`write\`, \`edit\` | \`edit\` | +| \`bash\`, \`shell\` | \`execute\` | +| \`web_search\`, \`web_fetch\` | \`search\` | +| Other | \`other\` | + +## Modes + +OpenCode doesn't have the same mode system as Claude Code. Instead, it uses permission rulesets per-session. Map to equivalent behavior: + +| Paseo Mode | OpenCode Equivalent | +|------------|---------------------| +| \`auto\` | Default permission rules | +| \`acceptEdits\` | Allow write/edit tools | +| \`plan\` | Limit to read-only tools | +| \`bypassPermissions\` | Allow all tools without prompting | + +## Testing Checklist + +- [ ] Server starts on first agent creation +- [ ] Server reuses for subsequent agents +- [ ] Multiple agents in same directory work +- [ ] Multiple agents in different directories work +- [ ] Tool calls display correctly with input/output +- [ ] Permission requests show and can be approved/denied +- [ ] Interrupting prompts works +- [ ] Mode switching works +- [ ] Model ID is correctly reported +- [ ] Session resume works +- [ ] Graceful server shutdown on Paseo exit + +## Files to Create/Modify + +1. **New**: \`packages/server/src/server/agent/providers/opencode-agent.ts\` +2. **Modify**: \`packages/server/src/server/agent/agent-sdk-types.ts\` - Add \`"opencode"\` to AgentProvider +3. **Modify**: \`packages/server/src/server/agent/agent-registry.ts\` - Register OpenCode client +4. **New**: \`packages/server/src/server/agent/providers/opencode-server-manager.ts\` - Server lifecycle +5. **Modify**: \`package.json\` - Add \`@opencode/sdk\` dependency (if using SDK) + +## Open Questions + +1. Should we use the SDK or make raw HTTP calls? SDK provides types but adds dependency. +2. How to handle OpenCode version mismatches between Paseo's expectation and user's installed version? +3. Should Paseo show OpenCode's configured model in UI, or let user override? + +## Reference Files + +- OpenCode server: \`/tmp/opencode-repo/packages/opencode/src/server/server.ts\` +- OpenCode session: \`/tmp/opencode-repo/packages/opencode/src/session/index.ts\` +- OpenCode permission: \`/tmp/opencode-repo/packages/opencode/src/permission/index.ts\` +- OpenCode SDK: \`/tmp/opencode-repo/packages/sdk/js/src/v2/\` +- Paseo Claude agent: \`packages/server/src/server/agent/providers/claude-agent.ts\` +- Paseo Codex agent: \`packages/server/src/server/agent/providers/codex-mcp-agent.ts\` +- Paseo agent types: \`packages/server/src/server/agent/agent-sdk-types.ts\` + +## Notes + +**2026-01-11T04:16:21.033Z** + +OpenCode repository cloned to /tmp/opencode-repo for inspection and debugging. This contains the full source code including server, SDK, CLI, and all internal modules. + +**2026-01-11T04:27:14.997Z** + +OpenCode CLI and server API verified working (v1.1.12). Server endpoints tested: POST /session creates session, POST /session/:id/message sends prompt and returns response. Response includes parts array with text content. Ready to implement provider integration. + +**2026-01-11T04:42:15.093Z** + +OpenCode agent provider implementation complete with TDD approach. All 5 tests passing: +1. Session creation +2. Text prompting/response +3. Streaming interruption +4. Tool call events (file edits) +5. Permission flow + +Uses @opencode-ai/sdk v1.1.12 with GLM-4.7-free model by default. diff --git a/.tasks/9b83b90a.md b/.tasks/9b83b90a.md new file mode 100644 index 000000000..e000a1354 --- /dev/null +++ b/.tasks/9b83b90a.md @@ -0,0 +1,92 @@ +--- +id: 9b83b90a +title: Refactor stream buffering to head/tail model +status: open +deps: [] +created: 2026-01-11T08:48:35.444Z +--- + +## Goal + +Replace the current special-case buffering logic with a unified head/tail model where: +- **Tail**: Committed StreamItem[] (history) +- **Head**: Active StreamItem[] (being streamed, same type as tail) + +Both use the same `reduceStreamUpdate()` function. The only difference is that head items get flushed to tail when a stream item completes. + +## Current Problems + +1. `applyStreamEventWithBuffer()` has hardcoded assistant_message buffering +2. `StreamingBufferEntry` is a different type than `StreamItem` +3. Reasoning chunks go through reducer directly (no buffering) +4. Duplicate logic between `appendAssistantMessage()` and buffer handling + +## Design + +### Types + +```typescript +// Head is just StreamItem[] - no special type needed +// Tail is just StreamItem[] - same as before + +interface StreamState { + tail: StreamItem[]; // committed history + head: StreamItem[]; // active streaming items +} +``` + +### Core Function + +```typescript +function applyStreamEvent(params: { + tail: StreamItem[]; + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; +}): { + tail: StreamItem[]; + head: StreamItem[]; + changedTail: boolean; + changedHead: boolean; +} +``` + +### Flush Logic + +Head items are flushed to tail when: +1. A different item kind arrives (thought → tool_call flushes the thought) +2. Turn completion events (turn_completed, turn_failed, turn_canceled) +3. User message arrives (new turn starting) + +### Implementation Steps + +1. Remove `StreamingBufferEntry` type +2. Remove `StreamingHead` type (or repurpose) +3. Modify `applyStreamEventWithBuffer` → `applyStreamEvent` to work with head/tail +4. Head uses same `reduceStreamUpdate` as tail +5. Add flush logic based on item kind transitions +6. Update session-context.tsx to use new API +7. Update agent-stream-view.tsx to render head items +8. Update session-store.ts types + +## Flush Conditions + +```typescript +function shouldFlushHead(head: StreamItem[], incomingKind: StreamItemKind): boolean { + if (head.length === 0) return false; + const lastHeadKind = head[head.length - 1].kind; + + // Different kind arriving = flush + if (lastHeadKind !== incomingKind) return true; + + return false; +} +``` + +## Rendering + +In agent-stream-view.tsx: +- Tail is rendered in FlatList (reversed, virtualized) +- Head is rendered in ListHeaderComponent (always visible, not virtualized since it's small) + +This is similar to current approach but head can now contain any StreamItem types. diff --git a/.tasks/b694a5c0.md b/.tasks/b694a5c0.md new file mode 100644 index 000000000..2fdcc9d31 --- /dev/null +++ b/.tasks/b694a5c0.md @@ -0,0 +1,9 @@ +--- +id: b694a5c0 +title: OpenCode: Tool calls test (file edits) +status: done +deps: [d0dc7bd2] +created: 2026-01-11T04:30:19.923Z +parentId: 90e02e62 +--- + diff --git a/.tasks/d0dc7bd2.md b/.tasks/d0dc7bd2.md new file mode 100644 index 000000000..6293dc2c2 --- /dev/null +++ b/.tasks/d0dc7bd2.md @@ -0,0 +1,9 @@ +--- +id: d0dc7bd2 +title: OpenCode: Basic session creation test +status: done +deps: [] +created: 2026-01-11T04:29:55.160Z +parentId: 90e02e62 +--- + diff --git a/.tasks/ea3f3266.md b/.tasks/ea3f3266.md new file mode 100644 index 000000000..8988d511c --- /dev/null +++ b/.tasks/ea3f3266.md @@ -0,0 +1,9 @@ +--- +id: ea3f3266 +title: OpenCode: Permission flow test +status: done +deps: [d0dc7bd2] +created: 2026-01-11T04:30:21.283Z +parentId: 90e02e62 +--- + diff --git a/package-lock.json b/package-lock.json index 9918148e4..c5b7c5e77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,7 @@ "packages/app" ], "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.75", - "@openai/codex-sdk": "^0.76.0" + "@anthropic-ai/claude-agent-sdk": "^0.1.75" }, "devDependencies": { "patch-package": "^8.0.1", @@ -5762,14 +5761,11 @@ "node": ">=12.0.0" } }, - "node_modules/@openai/codex-sdk": { - "version": "0.76.0", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.76.0.tgz", - "integrity": "sha512-0Z1znTuZ7g4wYrBDzEMWEKjA7y3qUdeS/nCtIiPakzxkrcFCm67W1P+7cDym9jnN2DnFTwLedZ0FRKGzQggQ/w==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } + "node_modules/@opencode-ai/sdk": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.12.tgz", + "integrity": "sha512-M1c+525865g2C03iKaMdx3aUVC7oZSNbBGaTDjUN2UTIakdxqKHog0Hd8IA9WL8M0aU1QJ8+CnpvRT2Jt9kRNw==", + "license": "MIT" }, "node_modules/@openrouter/ai-sdk-provider": { "version": "1.2.0", @@ -21337,6 +21333,7 @@ "@lezer/markdown": "^1.6.2", "@lezer/python": "^1.1.18", "@modelcontextprotocol/sdk": "^1.20.1", + "@opencode-ai/sdk": "^1.1.12", "@openrouter/ai-sdk-provider": "^1.2.0", "ai": "^5.0.76", "dotenv": "^17.2.3", diff --git a/package.json b/package.json index 701695cc8..a44c666d3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "lightningcss": "1.30.1" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.1.75", - "@openai/codex-sdk": "^0.76.0" + "@anthropic-ai/claude-agent-sdk": "^0.1.75" } } diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 938b27c94..0e7996a01 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -308,10 +308,10 @@ function AgentScreenContent({ return children; }, [allAgents, resolvedAgentId]); - // Select only the specific stream state - use stable empty array to avoid infinite loop + // Select only the specific stream tail - use stable empty array to avoid infinite loop const streamItemsRaw = useSessionStore((state) => resolvedAgentId - ? state.sessions[serverId]?.agentStreamState?.get(resolvedAgentId) + ? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId) : undefined ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 4b896f098..f63f0c627 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -10,13 +10,14 @@ import { } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronRight, Monitor } from "lucide-react-native"; +import { Monitor } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; import { AgentInputArea } from "@/components/agent-input-area"; import { DropdownSheet, GitOptionsSection, WorkingDirectoryDropdown, + AgentConfigRow, } from "@/components/agent-form/agent-form-dropdowns"; import { FileDropZone } from "@/components/file-drop-zone"; import { useDaemonRequest } from "@/hooks/use-daemon-request"; @@ -75,14 +76,6 @@ type DraftAgentParams = { workingDir?: string; }; -type ConfigRowProps = { - label: string; - value: string; - meta?: string; - onPress: () => void; - disabled?: boolean; -}; - export default function HomeScreen() { const { theme } = useUnistyles(); const router = useRouter(); @@ -160,9 +153,7 @@ export default function HomeScreen() { ? formatConnectionStatus(hostEntry.status) : undefined; - const [openDropdown, setOpenDropdown] = useState< - "host" | "provider" | "mode" | "model" | "agent" | null - >(null); + const [openDropdown, setOpenDropdown] = useState<"host" | null>(null); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); const [promptText, setPromptText] = useState(""); @@ -178,12 +169,9 @@ export default function HomeScreen() { const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { addImagesRef.current = addImages; }, []); - const openDropdownSheet = useCallback( - (key: "host" | "provider" | "mode" | "model" | "agent") => { - setOpenDropdown(key); - }, - [] - ); + const openDropdownSheet = useCallback((key: "host") => { + setOpenDropdown(key); + }, []); const closeDropdown = useCallback(() => { setOpenDropdown(null); }, []); @@ -414,25 +402,6 @@ export default function HomeScreen() { } }, [isNonGitDirectory, useWorktree]); - const renderConfigRow = useCallback( - ({ label, value, meta, onPress, disabled }: ConfigRowProps) => ( - - - {label} - - {value} - - {meta ? {meta} : null} - - - - ), - [theme.colors.mutedForeground] - ); const pendingRequestIdRef = useRef(null); const sessionMethods = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.methods : undefined @@ -581,16 +550,6 @@ export default function HomeScreen() { }; }, [router, selectedServerId, sessionWs]); - const selectedProviderLabel = - providerDefinitions.find((provider) => provider.id === selectedProvider)?.label ?? - selectedProvider; - const selectedModeLabel = - modeOptions.length > 0 - ? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? - modeOptions[0]?.label ?? - "Default" - : "Automatic"; - return ( @@ -630,11 +589,18 @@ export default function HomeScreen() { )} - {renderConfigRow({ - label: "Agent", - value: `${selectedProviderLabel} · ${selectedModel || "auto"} · ${selectedModeLabel}`, - onPress: () => openDropdownSheet("agent"), - })} + {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( - - - Provider - - {providerDefinitions.map((definition) => { - const isSelected = definition.id === selectedProvider; - return ( - { - setProviderFromUser(definition.id); - }} - > - - {definition.label} - - {definition.description ? ( - - {definition.description} - - ) : null} - - ); - })} - - - - - Model - - setModelFromUser("")} - > - - Automatic (provider default) - - - {availableModels.map((model) => { - const isSelected = model.id === selectedModel; - return ( - setModelFromUser(model.id)} - > - - {model.label} - - {model.description ? ( - - {model.description} - - ) : null} - - ); - })} - - - - {modeOptions.length > 0 ? ( - - Mode - - {modeOptions.map((mode) => { - const isSelected = mode.id === selectedMode; - return ( - setModeFromUser(mode.id)} - > - - {mode.label} - - {mode.description ? ( - - {mode.description} - - ) : null} - - ); - })} - - - ) : null} - - {errorMessage ? ( {errorMessage} @@ -855,39 +716,6 @@ const styles = StyleSheet.create((theme) => ({ alignSelf: "center", width: "100%", }, - configRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.card, - }, - configRowDisabled: { - opacity: theme.opacity[50], - }, - configTextGroup: { - flex: 1, - gap: theme.spacing[1], - marginRight: theme.spacing[2], - }, - configLabel: { - fontSize: theme.fontSize.xs, - textTransform: "uppercase", - letterSpacing: 0.6, - color: theme.colors.mutedForeground, - }, - configValue: { - fontSize: theme.fontSize.base, - color: theme.colors.foreground, - }, - configMeta: { - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - }, dropdownHelper: { fontSize: theme.fontSize.sm, color: theme.colors.mutedForeground, @@ -963,15 +791,6 @@ const styles = StyleSheet.create((theme) => ({ hostStatusDotOnline: { backgroundColor: theme.colors.palette.green[500], }, - agentSheetSection: { - marginBottom: theme.spacing[4], - }, - agentSheetSectionLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - marginBottom: theme.spacing[2], - }, loadingOverlay: { position: "absolute", top: 0, diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 19966173e..bb6444ff9 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -301,6 +301,308 @@ export function AdaptiveSelect({ ); } +interface ComboSelectOption { + id: string; + label: string; + description?: string; +} + +interface ComboSelectProps { + label: string; + title: string; + value: string; + options: ComboSelectOption[]; + placeholder?: string; + disabled?: boolean; + allowCustomValue?: boolean; + isLoading?: boolean; + onSelect: (id: string) => void; +} + +export function ComboSelect({ + label, + title, + value, + options, + placeholder, + disabled, + allowCustomValue = false, + isLoading, + onSelect, +}: ComboSelectProps): ReactElement { + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(""); + + const selectedOption = options.find((opt) => opt.id === value); + const displayValue = selectedOption?.label ?? (value || ""); + + const handleOpen = useCallback(() => setIsOpen(true), []); + const handleClose = useCallback(() => { + setIsOpen(false); + setSearchQuery(""); + }, []); + + useEffect(() => { + if (isOpen) { + setSearchQuery(""); + inputRef.current?.focus(); + } + }, [isOpen]); + + const normalizedSearch = searchQuery.trim().toLowerCase(); + const filteredOptions = useMemo(() => { + if (!normalizedSearch) { + return options; + } + return options.filter( + (opt) => + opt.label.toLowerCase().includes(normalizedSearch) || + opt.id.toLowerCase().includes(normalizedSearch) || + opt.description?.toLowerCase().includes(normalizedSearch) + ); + }, [options, normalizedSearch]); + + const hasMatches = filteredOptions.length > 0; + const sanitizedSearchValue = searchQuery.trim(); + const showCustomOption = + allowCustomValue && + sanitizedSearchValue.length > 0 && + !options.some( + (opt) => + opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || + opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() + ); + + const handleSelect = useCallback( + (id: string) => { + onSelect(id); + handleClose(); + }, + [handleClose, onSelect] + ); + + return ( + <> + + + + {showCustomOption ? ( + + handleSelect(sanitizedSearchValue)} + > + + {`Use "${sanitizedSearchValue}"`} + + + + ) : null} + {hasMatches ? ( + + {filteredOptions.map((opt) => { + const isSelected = opt.id === value; + return ( + handleSelect(opt.id)} + > + {opt.label} + {opt.description ? ( + + {opt.description} + + ) : null} + + ); + })} + + ) : !showCustomOption ? ( + No options match your search. + ) : null} + {isLoading ? ( + + + + ) : null} + + + ); +} + +interface CompactSelectFieldProps { + label: string; + value: string; + placeholder?: string; + onPress: () => void; + disabled?: boolean; + isLoading?: boolean; + controlRef?: React.RefObject; +} + +function CompactSelectField({ + label, + value, + placeholder, + onPress, + disabled, + isLoading, + controlRef, +}: CompactSelectFieldProps): ReactElement { + return ( + + {label} + {isLoading ? ( + + ) : ( + + {value || placeholder || "Select..."} + + )} + + ); +} + +interface AgentConfigRowProps { + providerDefinitions: AgentProviderDefinition[]; + selectedProvider: AgentProvider; + onSelectProvider: (provider: AgentProvider) => void; + modeOptions: AgentMode[]; + selectedMode: string; + onSelectMode: (modeId: string) => void; + models: AgentModelDefinition[]; + selectedModel: string; + isModelLoading: boolean; + onSelectModel: (modelId: string) => void; + disabled?: boolean; +} + +export function AgentConfigRow({ + providerDefinitions, + selectedProvider, + onSelectProvider, + modeOptions, + selectedMode, + onSelectMode, + models, + selectedModel, + isModelLoading, + onSelectModel, + disabled, +}: AgentConfigRowProps): ReactElement { + const providerOptions: ComboSelectOption[] = useMemo( + () => + providerDefinitions.map((def) => ({ + id: def.id, + label: def.label, + description: def.description, + })), + [providerDefinitions] + ); + + const modeSelectOptions: ComboSelectOption[] = useMemo(() => { + if (modeOptions.length === 0) { + return [{ id: "", label: "Default", description: "Provider default mode" }]; + } + return modeOptions.map((mode) => ({ + id: mode.id, + label: mode.label, + description: mode.description, + })); + }, [modeOptions]); + + const modelSelectOptions: ComboSelectOption[] = useMemo(() => { + const opts: ComboSelectOption[] = [ + { id: "", label: "Auto", description: "Provider default model" }, + ]; + for (const model of models) { + opts.push({ + id: model.id, + label: model.label, + description: model.description, + }); + } + return opts; + }, [models]); + + const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : ""); + + return ( + + + + + + + + + + + + ); +} + interface AssistantDropdownProps { providerDefinitions: AgentProviderDefinition[]; selectedProvider: AgentProvider; @@ -1184,4 +1486,38 @@ const styles = StyleSheet.create((theme) => ({ desktopDropdownScrollContent: { padding: theme.spacing[2], }, + agentConfigRow: { + flexDirection: "row", + gap: theme.spacing[2], + }, + agentConfigColumn: { + flex: 1, + }, + compactSelectControl: { + backgroundColor: theme.colors.background, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + gap: theme.spacing[1], + }, + compactSelectControlDisabled: { + opacity: theme.opacity[50], + }, + compactSelectLabel: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + compactSelectValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + compactSelectPlaceholder: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + }, })); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index a5cfc0d4c..69d6a0f79 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -16,7 +16,17 @@ import Markdown from "react-native-markdown-display"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; -import Animated, { FadeIn, FadeOut, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeOut, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; import { ChevronDown } from "lucide-react-native"; import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; import { @@ -72,19 +82,22 @@ export function AgentStreamView({ const isProgrammaticScrollRef = useRef(false); const isNearBottomRef = useRef(true); const isUserScrollingRef = useRef(false); - const { open: openExplorer, setActiveTab: setExplorerTab } = useExplorerSidebarStore(); + const { open: openExplorer, setActiveTab: setExplorerTab } = + useExplorerSidebarStore(); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? agent.serverId ?? ""; // Get ws for connection status const ws = useSessionStore((state) => state.sessions[resolvedServerId]?.ws); - const streamingBuffer = useSessionStore((state) => - state.sessions[resolvedServerId]?.agentStreamingBuffer?.get(agentId) + const streamHead = useSessionStore((state) => + state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) ); // Get methods for file operations - const methods = useSessionStore((state) => state.sessions[resolvedServerId]?.methods); + const methods = useSessionStore( + (state) => state.sessions[resolvedServerId]?.methods + ); const requestDirectoryListing = methods?.requestDirectoryListing; const requestFilePreview = methods?.requestFilePreview; @@ -109,8 +122,12 @@ export function AgentStreamView({ // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; - const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations ? undefined : FadeIn.duration(200); - const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations ? undefined : FadeOut.duration(200); + const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations + ? undefined + : FadeIn.duration(200); + const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations + ? undefined + : FadeOut.duration(200); useEffect(() => { hasScrolledInitially.current = false; @@ -265,7 +282,10 @@ export function AgentStreamView({ break; case "thought": - content = ; + console.log("[AgentStreamView] renderStreamItem", { item }); + content = ( + + ); break; case "tool_call": { @@ -343,9 +363,9 @@ export function AgentStreamView({ const listHeaderComponent = useMemo(() => { const hasPermissions = pendingPermissionItems.length > 0; - const hasStreaming = !!streamingBuffer; + const hasHeadItems = streamHead && streamHead.length > 0; - if (!hasPermissions && !showWorkingIndicator && !hasStreaming) { + if (!hasPermissions && !showWorkingIndicator && !hasHeadItems) { return null; } @@ -355,7 +375,11 @@ export function AgentStreamView({ {hasPermissions ? ( {pendingPermissionItems.map((permission) => ( - + ))} ) : null} @@ -366,19 +390,32 @@ export function AgentStreamView({ ) : null} - {hasStreaming ? ( - - - - ) : null} + {hasHeadItems + ? [...streamHead].reverse().map((item) => { + const rendered = renderStreamItem({ + item, + index: 0, + separators: { + highlight: () => {}, + unhighlight: () => {}, + updateProps: () => {}, + }, + }); + return rendered ? ( + {rendered} + ) : null; + }) + : null} ); - }, [pendingPermissionItems, showWorkingIndicator, wsOrInert, streamingBuffer, handleInlinePathPress]); + }, [ + pendingPermissionItems, + showWorkingIndicator, + wsOrInert, + streamHead, + renderStreamItem, + ]); const flatListData = useMemo(() => { return [...streamItems].reverse(); @@ -457,9 +494,7 @@ export function AgentStreamView({ function normalizeInlinePath( rawPath: string, cwd?: string -): - | { directory: string; file?: string } - | null { +): { directory: string; file?: string } | null { if (!rawPath) { return null; } @@ -506,7 +541,10 @@ function normalizePathInput(value: string | undefined): string | null { return null; } - const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, ""); + const trimmed = value + .trim() + .replace(/^['"`]/, "") + .replace(/['"`]$/, ""); if (!trimmed) { return null; } @@ -516,7 +554,11 @@ function normalizePathInput(value: string | undefined): string | null { function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null { const normalizedCwd = normalizePathInput(cwd); - if (!normalizedCwd || !isAbsolutePath(pathValue) || !isAbsolutePath(normalizedCwd)) { + if ( + !normalizedCwd || + !isAbsolutePath(pathValue) || + !isAbsolutePath(normalizedCwd) + ) { return null; } @@ -616,7 +658,9 @@ function PermissionRequestCard({ const { request } = permission; const title = request.title ?? request.name ?? "Permission Required"; const description = request.description ?? ""; - const inputPreview = request.input ? JSON.stringify(request.input, null, 2) : null; + const inputPreview = request.input + ? JSON.stringify(request.input, null, 2) + : null; const planMarkdown = useMemo(() => { if (!request) { @@ -651,10 +695,7 @@ function PermissionRequestCard({ [request] ); - const markdownStyles = useMemo( - () => createMarkdownStyles(theme), - [theme] - ); + const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownRules = useMemo(() => { return { @@ -676,7 +717,11 @@ function PermissionRequestCard({ styles: any, inheritedStyles: any = {} ) => ( - + {children} ), @@ -687,7 +732,11 @@ function PermissionRequestCard({ styles: any, inheritedStyles: any = {} ) => ( - + {node.content} ), @@ -709,7 +758,11 @@ function PermissionRequestCard({ styles: any, inheritedStyles: any = {} ) => ( - + {node.content} ), @@ -752,7 +805,10 @@ function PermissionRequestCard({ return ( {bullet} - + {children} @@ -799,7 +855,10 @@ function PermissionRequestCard({ response, }) .catch((error) => { - console.error("[PermissionRequestCard] Failed to respond to permission:", error); + console.error( + "[PermissionRequestCard] Failed to respond to permission:", + error + ); }); }, [permission.agentId, permission.request.id, permissionResponse] @@ -815,19 +874,33 @@ function PermissionRequestCard({ }, ]} > - + {title} {description ? ( - + {description} ) : null} {planMarkdown ? ( - Proposed Plan + + Proposed Plan + - {planMarkdown} + + {planMarkdown} + ) : null} {commandDetails ? ( - Command + + Command + {commandDetails.command ? ( - Command - + + Command + + {commandDetails.command} ) : null} {commandDetails.cwd ? ( - Directory - + + Directory + + {commandDetails.cwd} @@ -866,9 +972,19 @@ function PermissionRequestCard({ {editEntries.length > 0 ? ( - Proposed Changes + + Proposed Changes + {editEntries.map((entry, index) => ( - + {entry.filePath ? ( - + {entry.filePath} @@ -902,7 +1023,14 @@ function PermissionRequestCard({ {readEntries.length > 0 ? ( - File Content + + File Content + {readEntries.map((entry, index) => ( {entry.filePath} ) : null} - + {entry.content} @@ -934,7 +1070,14 @@ function PermissionRequestCard({ {inputPreview ? ( - Raw Request + + Raw Request + - + {inputPreview} @@ -970,7 +1118,10 @@ function PermissionRequestCard({ disabled={isResponding} > {isResponding ? ( - + ) : ( {isResponding ? ( - + ) : ( ({ container: { flex: 1, diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx deleted file mode 100644 index 8efb946d8..000000000 --- a/packages/app/src/components/create-agent-modal.tsx +++ /dev/null @@ -1,1464 +0,0 @@ -import { - useState, - useRef, - useEffect, - useMemo, - useCallback, -} from "react"; -import { createNameId } from "mnemonic-id"; -import type { ReactElement, ReactNode } from "react"; -import { - View, - Text, - Pressable, - ScrollView, - ActivityIndicator, - InteractionManager, - Modal, - useWindowDimensions, - type LayoutChangeEvent, - Platform, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, - Easing, - runOnJS, -} from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { X } from "lucide-react-native"; -import { theme as defaultTheme } from "@/styles/theme"; -import { useRecentPaths } from "@/hooks/use-recent-paths"; -import { useRouter } from "expo-router"; -import { generateMessageId } from "@/types/stream"; -import { MessageInput } from "./message-input"; -import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context"; -import type { - AgentProvider, - AgentSessionConfig, -} from "@server/server/agent/agent-sdk-types"; -import { useDaemonRequest } from "@/hooks/use-daemon-request"; -import type { WSInboundMessage, SessionOutboundMessage } from "@server/server/messages"; -import { formatConnectionStatus } from "@/utils/daemons"; -import { trackAnalyticsEvent } from "@/utils/analytics"; -import type { SessionContextValue } from "@/contexts/session-context"; -import type { UseWebSocketReturn } from "@/hooks/use-websocket"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { - AssistantDropdown, - DropdownSheet, - GitOptionsSection, - ModelDropdown, - PermissionsDropdown, - WorkingDirectoryDropdown, -} from "@/components/agent-form/agent-form-dropdowns"; -import { - useAgentFormState, - type CreateAgentInitialValues, -} from "@/hooks/use-agent-form-state"; - -interface AgentFlowModalProps { - isVisible: boolean; - onClose: () => void; - initialValues?: CreateAgentInitialValues; - serverId?: string | null; - onAfterClose?: () => void; -} - -interface ModalWrapperProps { - isVisible: boolean; - onClose: () => void; - initialValues?: CreateAgentInitialValues; - serverId?: string | null; -} - -type CreateAgentSessionSlice = { - serverId: string; - ws: UseWebSocketReturn | null; - createAgent: (options: { - config: any; - initialPrompt: string; - git?: any; - worktreeName?: string; - requestId?: string; - }) => void; - sendAgentAudio: ( - agentId: string | undefined, - audioBlob: Blob, - requestId?: string, - options?: { mode?: "transcribe_only" | "auto_run" } - ) => Promise; - agents: Map; -}; - -const BACKDROP_OPACITY = 0.55; -const IS_WEB = Platform.OS === "web"; - -type DropdownKey = - | "assistant" - | "permissions" - | "model" - | "workingDir" - | "baseBranch" - | "host"; - -type RepoInfoState = { - cwd: string; - repoRoot: string; - branches: Array<{ name: string; isCurrent: boolean }>; - currentBranch: string | null; - isDirty: boolean; -}; - -type GitRepoInfoResponseMessage = Extract< - SessionOutboundMessage, - { type: "git_repo_info_response" } ->; - -function AgentFlowModal({ - isVisible, - onClose, - initialValues, - serverId, - onAfterClose, -}: AgentFlowModalProps) { - const insets = useSafeAreaInsets(); - const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - const slideOffset = useSharedValue(screenHeight); - const backdropOpacity = useSharedValue(0); - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const shouldAutoFocusPrompt = IS_WEB; - - const { addRecentPath } = useRecentPaths(); - const { connectionStates } = useDaemonConnections(); - const daemonEntries = useMemo(() => Array.from(connectionStates.values()), [connectionStates]); - const initialServerId = useMemo(() => { - if (!serverId) { - return null; - } - const exists = daemonEntries.some((entry) => entry.daemon.id === serverId); - return exists ? serverId : null; - }, [serverId, daemonEntries]); - const { - selectedServerId, - setSelectedServerId, - setSelectedServerIdFromUser, - selectedProvider, - setProviderFromUser, - selectedMode, - setModeFromUser, - selectedModel, - setModelFromUser, - workingDir, - setWorkingDirFromUser, - providerDefinitions, - providerDefinitionMap, - modeOptions, - availableModels, - isModelLoading, - modelError, - refreshProviderModels, - queueProviderModelFetch, - clearQueuedProviderModelRequest, - workingDirIsEmpty, - } = useAgentFormState({ - initialServerId, - initialValues, - isVisible, - isCreateFlow: true, - }); - - const sessionState = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId] : undefined - ); - - // Extract only what we need for CreateAgentSessionSlice - const session = useMemo(() => { - if (!selectedServerId || !sessionState || !sessionState.methods) { - return null; - } - const slice: CreateAgentSessionSlice = { - serverId: selectedServerId, - ws: sessionState.ws, - createAgent: sessionState.methods.createAgent, - sendAgentAudio: sessionState.methods.sendAgentAudio, - agents: sessionState.agents, - }; - return slice; - }, [selectedServerId, sessionState]); - - useEffect(() => { - if (selectedServerId) { - const exists = daemonEntries.some((entry) => entry.daemon.id === selectedServerId); - if (!exists) { - setSelectedServerId(initialServerId); - } - return; - } - if (initialServerId && selectedServerId !== initialServerId) { - setSelectedServerId(initialServerId); - } - }, [daemonEntries, selectedServerId, initialServerId]); - - useEffect(() => { - if (!isVisible) { - return; - } - if (initialServerId && selectedServerId !== initialServerId) { - setSelectedServerId(initialServerId); - } - }, [isVisible, initialServerId, selectedServerId]); - - useEffect(() => { - if (!isVisible || initialServerId || selectedServerId) { - return; - } - const firstReady = daemonEntries.find( - ({ status, sessionReady }) => status === "online" && sessionReady - ); - if (firstReady) { - setSelectedServerId(firstReady.daemon.id); - } - }, [daemonEntries, initialServerId, isVisible, selectedServerId]); - - const inertWebSocket = useMemo( - () => ({ - isConnected: false, - isConnecting: false, - conversationId: null, - lastError: null, - send: () => {}, - on: () => () => {}, - sendPing: () => {}, - sendUserMessage: () => {}, - clearAgentAttention: () => {}, - subscribeConnectionStatus: () => () => {}, - getConnectionState: () => ({ isConnected: false, isConnecting: false }), - }), - [] - ); - const ws = session?.ws ?? null; - const effectiveWs: UseWebSocketReturn = ws ?? inertWebSocket; - const createAgent = session?.createAgent; - const sessionSendAgentAudio = session?.sendAgentAudio; - const noopSendAgentAudio = useCallback(async () => {}, []); - const sendAgentAudio = sessionSendAgentAudio ?? noopSendAgentAudio; - const hasSendAgentAudio = Boolean(sessionSendAgentAudio); - const agents = session?.agents; - const agentWorkingDirSuggestions = useMemo(() => { - if (!selectedServerId || !agents) { - return []; - } - const uniquePaths = new Set(); - agents.forEach((agent) => { - if (agent.cwd) { - uniquePaths.add(agent.cwd); - } - }); - return Array.from(uniquePaths); - }, [agents, selectedServerId]); - const gitRepoInfoRequest = useDaemonRequest< - { cwd: string }, - RepoInfoState, - GitRepoInfoResponseMessage - >({ - ws: effectiveWs, - responseType: "git_repo_info_response", - buildRequest: ({ params, requestId }) => ({ - type: "session", - message: { - type: "git_repo_info_request", - cwd: params?.cwd ?? ".", - requestId, - }, - }), - getRequestKey: (params) => params?.cwd ?? "default", - selectData: (message) => ({ - cwd: message.payload.cwd, - repoRoot: message.payload.repoRoot, - branches: message.payload.branches ?? [], - currentBranch: message.payload.currentBranch ?? null, - isDirty: Boolean(message.payload.isDirty), - }), - extractError: (message) => - message.payload.error ? new Error(message.payload.error) : null, - keepPreviousData: false, - }); - const { - status: repoRequestStatus, - data: repoInfo, - error: repoRequestError, - execute: inspectRepoInfo, - reset: resetRepoInfo, - cancel: cancelRepoInfo, - } = gitRepoInfoRequest; - const isWsConnected = effectiveWs.getConnectionState - ? effectiveWs.getConnectionState().isConnected - : effectiveWs.isConnected; - const router = useRouter(); - const sessionServerId = session?.serverId ?? null; - const selectedDaemonId = selectedServerId ?? sessionServerId; - const selectedDaemonConnection = selectedDaemonId - ? connectionStates.get(selectedDaemonId) - : null; - const selectedDaemonStatus: ConnectionStatus = - selectedDaemonConnection?.status ?? - (ws?.isConnected - ? "online" - : ws?.isConnecting - ? "connecting" - : ws?.lastError - ? "error" - : "offline"); - const selectedDaemonLabel = - selectedDaemonConnection?.daemon.label ?? - selectedDaemonConnection?.daemon.wsUrl ?? - selectedDaemonId ?? - "Selected host"; - const selectedDaemonStatusLabel = formatConnectionStatus(selectedDaemonStatus); - const hasSelectedDaemon = Boolean(selectedServerId); - const selectedDaemonIsOffline = selectedDaemonStatus !== "online"; - const selectedDaemonLastError = selectedDaemonConnection?.lastError?.trim(); - const daemonAvailabilityError = !hasSelectedDaemon - ? "Select a host before creating agents." - : selectedDaemonIsOffline - ? `${selectedDaemonLabel} is ${selectedDaemonStatusLabel}. We'll reconnect automatically and enable actions once it's online.${ - selectedDaemonLastError ? ` ${selectedDaemonLastError}` : "" - }` - : null; - const selectedDaemonSessionReady = - selectedDaemonConnection?.status === "online" && - selectedDaemonConnection.sessionReady; - const isTargetDaemonReady = Boolean( - hasSelectedDaemon && selectedDaemonSessionReady && !selectedDaemonIsOffline - ); - - const [isMounted, setIsMounted] = useState(isVisible); - const [initialPrompt, setInitialPrompt] = useState(""); - const [useWorktree, setUseWorktree] = useState(false); - const [baseBranch, setBaseBranch] = useState(""); - const [worktreeSlug, setWorktreeSlug] = useState(""); - const [errorMessage, setErrorMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [openDropdown, setOpenDropdown] = useState(null); - const pendingRequestIdRef = useRef(null); - - const hasPendingCreate = pendingRequestIdRef.current !== null; - const shouldListenForStatus = isVisible || hasPendingCreate; - - const idleProviderPrefetchHandleRef = useRef | null>(null); - const pendingNavigationAgentIdRef = useRef(null); - const pendingNavigationServerIdRef = useRef(null); - const openDropdownSheet = useCallback((key: DropdownKey) => { - setOpenDropdown(key); - }, []); - const closeDropdown = useCallback(() => { - setOpenDropdown(null); - }, []); - - const handleUserWorkingDirChange = useCallback( - (value: string) => { - setWorkingDirFromUser(value); - setErrorMessage(""); - }, - [setWorkingDirFromUser] - ); - - const logOfflineDaemonAction = useCallback( - (action: "create" | "dictation", reason?: string | null) => { - trackAnalyticsEvent({ - type: "offline_daemon_action_attempt", - action, - daemonId: selectedDaemonId, - status: selectedDaemonStatus ?? null, - reason: reason ?? daemonAvailabilityError, - }); - }, - [ - daemonAvailabilityError, - selectedDaemonId, - selectedDaemonStatus, - ] - ); - - const resetFormState = useCallback(() => { - setInitialPrompt(""); - setUseWorktree(false); - setBaseBranch(""); - setWorktreeSlug(""); - setErrorMessage(""); - setIsLoading(false); - resetRepoInfo(); - setOpenDropdown(null); - pendingRequestIdRef.current = null; - pendingNavigationServerIdRef.current = null; - cancelRepoInfo(); - }, [cancelRepoInfo, resetRepoInfo]); - - const navigateToAgentIfNeeded = useCallback(() => { - const agentId = pendingNavigationAgentIdRef.current; - const targetServerId = pendingNavigationServerIdRef.current ?? selectedDaemonId; - if (!agentId || !targetServerId) { - return; - } - - pendingNavigationAgentIdRef.current = null; - pendingNavigationServerIdRef.current = null; - InteractionManager.runAfterInteractions(() => { - router.push({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: targetServerId, - agentId, - }, - }); - }); - }, [router, selectedDaemonId]); - - const handleSelectServer = useCallback( - (serverId: string) => { - setSelectedServerIdFromUser(serverId); - }, - [setSelectedServerIdFromUser] - ); - - const handleCloseAnimationComplete = useCallback(() => { - console.log("[CreateAgentModal] close animation complete – resetting form"); - resetFormState(); - setIsMounted(false); - navigateToAgentIfNeeded(); - onAfterClose?.(); - }, [navigateToAgentIfNeeded, onAfterClose, resetFormState]); - - useEffect(() => { - if (!isVisible) { - console.log( - "[CreateAgentModal] visibility effect skipped (isVisible is false)", - { - isMounted, - } - ); - return; - } - - console.log("[CreateAgentModal] visibility effect triggered", { - wasMounted: isMounted, - screenHeight, - }); - setIsMounted(true); - slideOffset.value = screenHeight; - backdropOpacity.value = 0; - - backdropOpacity.value = withTiming(BACKDROP_OPACITY, { - duration: 200, - easing: Easing.out(Easing.cubic), - }); - slideOffset.value = withTiming(0, { - duration: 240, - easing: Easing.out(Easing.cubic), - }); - }, [isVisible, slideOffset, backdropOpacity, screenHeight]); - - useEffect(() => { - if (!isMounted || isVisible) { - console.log("[CreateAgentModal] close animation skipped", { - isMounted, - isVisible, - }); - return; - } - - console.log("[CreateAgentModal] close animation starting", { - screenHeight, - }); - backdropOpacity.value = withTiming(0, { - duration: 160, - easing: Easing.out(Easing.cubic), - }); - slideOffset.value = withTiming( - screenHeight, - { - duration: 220, - easing: Easing.in(Easing.cubic), - }, - (finished) => { - if (finished) { - console.log("[CreateAgentModal] slide animation finished"); - runOnJS(handleCloseAnimationComplete)(); - } - } - ); - }, [ - isMounted, - isVisible, - slideOffset, - backdropOpacity, - screenHeight, - handleCloseAnimationComplete, - ]); - - - useEffect(() => { - return () => { - idleProviderPrefetchHandleRef.current?.cancel?.(); - }; - }, []); - - const footerAnimatedStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - insets.bottom); - return { - transform: [{ translateY: -shift }], - }; - }, [insets.bottom, keyboardHeight]); - - const containerAnimatedStyle = useAnimatedStyle(() => { - "worklet"; - return { - transform: [{ translateY: slideOffset.value }], - }; - }, [slideOffset]); - - const backdropAnimatedStyle = useAnimatedStyle(() => { - "worklet"; - return { - opacity: backdropOpacity.value, - }; - }, [backdropOpacity]); - - const handleUseWorktreeChange = useCallback((value: boolean) => { - setUseWorktree(value); - if (value && !worktreeSlug) { - setWorktreeSlug(createNameId()); - } - }, [worktreeSlug]); - - const validateWorktreeName = useCallback( - (name: string): { valid: boolean; error?: string } => { - if (!name) { - return { valid: true }; - } - - if (name.length > 100) { - return { - valid: false, - error: "Worktree name too long (max 100 characters)", - }; - } - - if (!/^[a-z0-9-/]+$/.test(name)) { - return { - valid: false, - error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - - if (name.startsWith("-") || name.endsWith("-")) { - return { valid: false, error: "Cannot start or end with a hyphen" }; - } - - if (name.includes("--")) { - return { valid: false, error: "Cannot have consecutive hyphens" }; - } - - return { valid: true }; - }, - [] - ); - - const handleClose = useCallback(() => { - onClose(); - }, [onClose]); - - - useEffect(() => { - idleProviderPrefetchHandleRef.current?.cancel?.(); - idleProviderPrefetchHandleRef.current = InteractionManager.runAfterInteractions(() => { - daemonEntries.forEach(({ daemon, status }) => { - const serverId = daemon.id; - const isSelected = serverId === selectedServerId; - const isReady = status === "online" || status === "idle"; - if (isSelected || !isReady) { - clearQueuedProviderModelRequest(serverId); - return; - } - queueProviderModelFetch(serverId, { delayMs: 320 }); - }); - }); - return () => { - idleProviderPrefetchHandleRef.current?.cancel?.(); - }; - }, [ - clearQueuedProviderModelRequest, - daemonEntries, - queueProviderModelFetch, - selectedProvider, - selectedServerId, - ]); - - const trimmedWorkingDir = workingDir.trim(); - const shouldInspectRepo = isVisible && trimmedWorkingDir.length > 0; - const repoAvailabilityError = shouldInspectRepo && (!isTargetDaemonReady || !isWsConnected) - ? daemonAvailabilityError ?? - "Repository details will load automatically once the selected host is back online." - : null; - const isNonGitDirectory = - repoRequestStatus === "error" && - /not in a git repository/i.test(repoRequestError?.message ?? ""); - const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo - ? "idle" - : repoAvailabilityError - ? "error" - : repoRequestStatus === "loading" - ? "loading" - : repoRequestStatus === "error" - ? isNonGitDirectory - ? "idle" - : "error" - : repoRequestStatus === "success" - ? "ready" - : "idle"; - const repoInfoError = repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); - const gitHelperText = isNonGitDirectory - ? "No git repository detected. Git options are disabled for this directory." - : null; - - useEffect(() => { - if (!shouldInspectRepo) { - cancelRepoInfo(); - resetRepoInfo(); - return; - } - - if (repoAvailabilityError) { - cancelRepoInfo(); - return; - } - - inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {}); - return () => { - cancelRepoInfo(); - }; - }, [ - cancelRepoInfo, - inspectRepoInfo, - repoAvailabilityError, - resetRepoInfo, - shouldInspectRepo, - trimmedWorkingDir, - ]); - - useEffect(() => { - if (isNonGitDirectory && useWorktree) { - setUseWorktree(false); - } - }, [isNonGitDirectory, useWorktree]); - - const gitBlockingError = useMemo(() => { - if (!useWorktree || isNonGitDirectory) { - return null; - } - if (!worktreeSlug) { - return null; - } - const validation = validateWorktreeName(worktreeSlug); - if (!validation.valid) { - return `Invalid worktree name: ${ - validation.error ?? "Must use lowercase letters, numbers, or hyphens" - }`; - } - return null; - }, [ - useWorktree, - isNonGitDirectory, - worktreeSlug, - validateWorktreeName, - ]); - - const baseBranchError = useMemo(() => { - if (!useWorktree || isNonGitDirectory || !baseBranch) { - return null; - } - const branches = repoInfo?.branches ?? []; - if (branches.length === 0) { - return null; - } - const branchExists = branches.some((b) => b.name === baseBranch); - if (!branchExists) { - return `Branch "${baseBranch}" not found in repository`; - } - return null; - }, [useWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); - - const handleBaseBranchChange = useCallback((value: string) => { - setBaseBranch(value); - }, []); - - const handleCreate = useCallback(async () => { - const trimmedPath = workingDir.trim(); - if (!trimmedPath) { - setErrorMessage("Working directory is required"); - return; - } - - const trimmedPrompt = initialPrompt.trim(); - if (!trimmedPrompt) { - setErrorMessage("Initial prompt is required"); - return; - } - - if (isLoading) { - return; - } - - if (gitBlockingError) { - setErrorMessage(gitBlockingError); - return; - } - - if (baseBranchError) { - setErrorMessage(baseBranchError); - return; - } - - if (!createAgent || !isTargetDaemonReady) { - logOfflineDaemonAction("create"); - setErrorMessage( - daemonAvailabilityError ?? - "Creating agents is temporarily unavailable while the selected host is offline. Paseo reconnects automatically—try again once it's online." - ); - return; - } - - try { - await addRecentPath(trimmedPath); - } catch (error) { - console.error("[CreateAgentModal] Failed to save recent path:", error); - } - - const requestId = generateMessageId(); - - pendingRequestIdRef.current = requestId; - pendingNavigationServerIdRef.current = selectedDaemonId ?? null; - setIsLoading(true); - setErrorMessage(""); - - const modeId = - modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const trimmedModel = selectedModel.trim(); - - const config: AgentSessionConfig = { - provider: selectedProvider, - cwd: trimmedPath, - ...(modeId ? { modeId } : {}), - ...(trimmedModel ? { model: trimmedModel } : {}), - }; - - const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined; - const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug - ? { - createWorktree: true, - worktreeSlug, - baseBranch: effectiveBaseBranch, - } - : undefined; - - try { - createAgent({ - config, - initialPrompt: trimmedPrompt, - git: gitOptions, - requestId, - }); - } catch (error) { - console.error("[CreateAgentModal] Failed to create agent:", error); - setErrorMessage("Failed to create agent. Please try again."); - setIsLoading(false); - pendingRequestIdRef.current = null; - pendingNavigationServerIdRef.current = null; - } - }, [ - workingDir, - initialPrompt, - useWorktree, - baseBranch, - worktreeSlug, - repoInfo?.currentBranch, - selectedMode, - modeOptions, - logOfflineDaemonAction, - selectedProvider, - isLoading, - addRecentPath, - createAgent, - daemonAvailabilityError, - isTargetDaemonReady, - selectedDaemonId, - isNonGitDirectory, - gitBlockingError, - baseBranchError, - selectedModel, - ]); - - useEffect(() => { - if (!shouldListenForStatus || !ws) { - return; - } - const unsubscribe = ws.on("status", (message) => { - if (message.type !== "status") { - return; - } - - const payload = message.payload as { - status: string; - agentId?: string; - requestId?: string; - error?: string; - }; - - if (payload.status === "agent_create_failed") { - const expectedRequestId = pendingRequestIdRef.current; - if (!expectedRequestId || payload.requestId !== expectedRequestId) { - return; - } - pendingRequestIdRef.current = null; - pendingNavigationServerIdRef.current = null; - setIsLoading(false); - setErrorMessage( - payload.error ?? - "Failed to create agent. Resolve git issues or try again." - ); - return; - } - - if (payload.status !== "agent_created" || !payload.agentId) { - return; - } - - const expectedRequestId = pendingRequestIdRef.current; - if (!expectedRequestId || payload.requestId !== expectedRequestId) { - return; - } - - console.log("[CreateAgentModal] Agent created:", payload.agentId); - pendingRequestIdRef.current = null; - setIsLoading(false); - pendingNavigationAgentIdRef.current = payload.agentId; - handleClose(); - }); - - return () => { - unsubscribe(); - }; - }, [handleClose, shouldListenForStatus, ws]); - - const shouldRender = isVisible || isMounted; - - const promptIsEmpty = !initialPrompt.trim(); - const createDisabled = - workingDirIsEmpty || - promptIsEmpty || - Boolean(gitBlockingError) || - Boolean(baseBranchError) || - isLoading || - !isTargetDaemonReady; - const headerPaddingTop = useMemo( - () => insets.top + defaultTheme.spacing[4], - [insets.top] - ); - const horizontalPaddingLeft = useMemo( - () => defaultTheme.spacing[6] + insets.left, - [insets.left] - ); - const horizontalPaddingRight = useMemo( - () => defaultTheme.spacing[6] + insets.right, - [insets.right] - ); - - const handleSheetLayout = useCallback((event: LayoutChangeEvent) => { - const { height, y } = event.nativeEvent.layout; - console.log("[CreateAgentModal] sheet layout", { height, y }); - }, []); - - if (!shouldRender) { - // console.log("[CreateAgentModal] render skipped", { - // isVisible, - // isMounted, - // }); - return null; - } - - // console.log("[CreateAgentModal] rendering modal", { - // isVisible, - // isMounted, - // }); - - return ( - - - - - - - - - <> - - - Target Host - {daemonEntries.length === 0 ? ( - No hosts available - ) : ( - - {daemonEntries.map(({ daemon, status }) => { - const isSelected = daemon.id === selectedServerId; - const label = daemon.label || daemon.wsUrl; - return ( - handleSelectServer(daemon.id)} - style={[styles.daemonChip, isSelected && styles.daemonChipSelected]} - > - - {label} - - - {formatConnectionStatus(status)} - - - ); - })} - - )} - {daemonAvailabilityError ? ( - - {daemonAvailabilityError} - - ) : null} - - - - Initial Prompt - { - setInitialPrompt(text); - setErrorMessage(""); - }} - onSubmit={() => { - void handleCreate(); - }} - ws={effectiveWs} - sendAgentAudio={sendAgentAudio} - placeholder="Describe what you want the agent to do" - autoFocus={shouldAutoFocusPrompt} - disabled={isLoading || !isTargetDaemonReady} - isSubmitDisabled={createDisabled} - /> - - - - - - - {!isNonGitDirectory ? ( - - ) : null} - - - - - {isLoading ? ( - - - Creating... - - ) : ( - Create Agent - )} - - - - - - - - ); -} - -function LazyCreateAgentModal(props: Omit) { - const { isVisible } = props; - const [shouldRender, setShouldRender] = useState(isVisible); - - useEffect(() => { - if (isVisible) { - setShouldRender(true); - } - }, [isVisible]); - - const handleAfterClose = useCallback(() => { - setShouldRender(false); - }, []); - - if (!shouldRender) { - return null; - } - - return ; -} - -export function CreateAgentModal(props: ModalWrapperProps) { - return ; -} - -interface ModalHeaderProps { - paddingTop: number; - paddingLeft: number; - paddingRight: number; - onClose: () => void; - title: string; - rightContent?: ReactNode; -} - -function ModalHeader({ - paddingTop, - paddingLeft, - paddingRight, - onClose, - title, - rightContent, -}: ModalHeaderProps): ReactElement { - return ( - - {title} - - {rightContent} - - - - - - ); -} - -const styles = StyleSheet.create(((theme: any) => ({ - overlay: { - flex: 1, - justifyContent: "flex-end", - }, - backdrop: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: theme.colors.palette.gray[900], - zIndex: 1, - }, - backdropPressable: { - flex: 1, - }, - sheet: { - position: "absolute", - left: 0, - right: 0, - top: 0, - bottom: 0, - zIndex: 2, - width: "100%", - backgroundColor: theme.colors.card, - overflow: "hidden", - }, - content: { - flex: 1, - position: "relative", - }, - header: { - paddingBottom: theme.spacing[4], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - headerTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize["2xl"], - fontWeight: theme.fontWeight.semibold, - }, - headerActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - }, - closeButton: { - padding: theme.spacing[2], - borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", - }, - scroll: { - flex: 1, - }, - scrollContent: { - paddingTop: theme.spacing[6], - paddingBottom: theme.spacing[8], - gap: theme.spacing[6], - }, - formSection: { - gap: theme.spacing[3], - }, - label: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - input: { - backgroundColor: theme.colors.background, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - dropdownControl: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - backgroundColor: theme.colors.background, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - }, - dropdownControlDisabled: { - opacity: theme.opacity[50], - }, - dropdownValue: { - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - dropdownPlaceholder: { - flex: 1, - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.base, - }, - dropdownSearchInput: { - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - backgroundColor: theme.colors.background, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - color: theme.colors.foreground, - }, - dropdownLoading: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - dropdownSheetOverlay: { - flex: 1, - justifyContent: "flex-end", - }, - dropdownSheetBackdrop: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - backgroundColor: theme.colors.palette.gray[900], - opacity: 0.45, - }, - dropdownSheetContainer: { - backgroundColor: theme.colors.card, - borderTopLeftRadius: theme.borderRadius["2xl"], - borderTopRightRadius: theme.borderRadius["2xl"], - paddingTop: theme.spacing[4], - paddingHorizontal: theme.spacing[5], - paddingBottom: theme.spacing[6] + theme.spacing[2], - maxHeight: 560, - width: "100%", - }, - dropdownSheetHandle: { - width: 56, - height: 4, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.border, - alignSelf: "center", - marginBottom: theme.spacing[3], - }, - dropdownSheetTitle: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - marginBottom: theme.spacing[4], - }, - dropdownSheetScrollContent: { - paddingBottom: theme.spacing[8], - paddingHorizontal: theme.spacing[1], - }, - dropdownSheetList: { - marginTop: theme.spacing[3], - }, - dropdownSheetOption: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - backgroundColor: theme.colors.background, - marginBottom: theme.spacing[2], - }, - dropdownSheetOptionSelected: { - borderColor: theme.colors.palette.blue[400], - backgroundColor: "rgba(59, 130, 246, 0.18)", - }, - dropdownSheetOptionLabel: { - color: theme.colors.foreground, - fontWeight: theme.fontWeight.semibold, - }, - dropdownSheetOptionDescription: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - dropdownSheetLoading: { - alignItems: "center", - paddingVertical: theme.spacing[4], - }, - inputDisabled: { - opacity: theme.opacity[50], - }, - errorText: { - color: theme.colors.palette.red[500], - fontSize: theme.fontSize.sm, - }, - warningText: { - color: theme.colors.palette.orange[400], - fontSize: theme.fontSize.sm, - }, - helperText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - }, - selectorRow: { - flexDirection: "row", - gap: theme.spacing[4], - }, - selectorRowStacked: { - flexDirection: "column", - }, - selectorColumn: { - flex: 1, - gap: theme.spacing[3], - }, - selectorColumnFull: { - width: "100%", - }, - toggleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - toggleRowDisabled: { - opacity: theme.opacity[50], - }, - checkbox: { - width: 22, - height: 22, - borderRadius: theme.borderRadius.sm, - borderWidth: theme.borderWidth[2], - borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - }, - checkboxChecked: { - borderColor: theme.colors.palette.blue[500], - backgroundColor: theme.colors.palette.blue[500], - }, - checkboxDisabled: { - borderColor: theme.colors.border, - }, - checkboxDot: { - width: 10, - height: 10, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.palette.white, - }, - toggleTextContainer: { - flex: 1, - gap: theme.spacing[1], - }, - toggleLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - }, - daemonSelectorSection: { - marginBottom: theme.spacing[6], - }, - daemonSelectorLabel: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - textTransform: "uppercase", - letterSpacing: 0.5, - marginBottom: theme.spacing[2], - }, - daemonSelectorChips: { - flexDirection: "row", - gap: theme.spacing[2], - }, - daemonChip: { - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - marginRight: theme.spacing[2], - }, - daemonChipSelected: { - backgroundColor: theme.colors.palette.blue[900], - borderColor: theme.colors.palette.blue[500], - }, - daemonChipText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - }, - daemonChipTextSelected: { - color: theme.colors.palette.white, - }, - daemonChipStatus: { - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - }, - daemonAvailabilityText: { - marginTop: theme.spacing[2], - marginBottom: theme.spacing[2], - color: theme.colors.destructive, - fontSize: theme.fontSize.sm, - }, - footer: { - borderTopWidth: theme.borderWidth[1], - borderTopColor: theme.colors.border, - paddingTop: theme.spacing[4], - backgroundColor: theme.colors.card, - }, - createButton: { - backgroundColor: theme.colors.palette.blue[500], - paddingVertical: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - alignItems: "center", - }, - createButtonDisabled: { - backgroundColor: theme.colors.palette.blue[900], - opacity: theme.opacity[50], - }, - createButtonText: { - color: theme.colors.palette.white, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - }, - loadingContainer: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, -})) as any) as Record; diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index a7ef832e1..2c8378f69 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -4,9 +4,10 @@ import { Text, ActivityIndicator, Pressable, - RefreshControl, + FlatList, type NativeSyntheticEvent, type NativeScrollEvent, + type ListRenderItem, } from "react-native"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -258,6 +259,18 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { state.sessions[serverId]?.agents?.has(agentId) ?? false ); + const renderFileSection: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [] + ); + + const keyExtractor = useCallback( + (item: ParsedDiffFile, index: number) => `${item.path}-${index}`, + [] + ); + if (!agentExists) { return ( @@ -269,40 +282,45 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const hasChanges = files.length > 0; const errorMessage = isError && error instanceof Error ? error.message : null; - return ( - - } - > - - {isLoading ? ( - - - Loading changes... - - ) : isError ? ( - - {errorMessage ?? "Failed to load changes"} - - ) : !hasChanges ? ( - - No changes - - ) : ( - files.map((file, fileIndex) => ( - - )) - )} + if (isLoading) { + return ( + + + Loading changes... - + ); + } + + if (isError) { + return ( + + {errorMessage ?? "Failed to load changes"} + + ); + } + + if (!hasChanges) { + return ( + + No changes + + ); + } + + return ( + ); } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d58deda56..42214b1b7 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -22,16 +22,26 @@ import { Search, Brain, } from "lucide-react-native"; -import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; +import { + StyleSheet, + useUnistyles, + UnistylesRuntime, +} from "react-native-unistyles"; import { baseColors, theme } from "@/styles/theme"; -import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles"; +import { + createMarkdownStyles, + createCompactMarkdownStyles, +} from "@/styles/markdown-styles"; import { Colors, Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry, ThoughtStatus } from "@/types/stream"; import { extractPrincipalParam } from "@/utils/tool-call-parsers"; import { resolveToolCallPreview } from "./tool-call-preview"; import { useToolCallSheet } from "./tool-call-sheet"; -import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details"; +import { + ToolCallDetailsContent, + useToolCallDetails, +} from "./tool-call-details"; interface UserMessageProps { message: string; @@ -259,13 +269,17 @@ function isLikelyPathToken(value: string): boolean { return false; } - const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../"); + const looksLikeDir = + value.endsWith("/") || value.startsWith("./") || value.startsWith("../"); return hasExtension || looksLikeDir || value.includes("/"); } function normalizeInlinePathValue(value: string): string | null { - const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, ""); + const trimmed = value + .trim() + .replace(/^['"`]/, "") + .replace(/['"`]$/, ""); if (!trimmed) { return null; } @@ -290,7 +304,9 @@ function parseInlinePathToken( return null; } const lineStart = parseInt(rangeOnlyMatch[1], 10); - const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined; + const lineEnd = rangeOnlyMatch[2] + ? parseInt(rangeOnlyMatch[2], 10) + : undefined; return { raw: rawValue, path: basePath, @@ -335,10 +351,7 @@ export const AssistantMessage = memo(function AssistantMessage({ const { theme } = useUnistyles(); const lastPathRef = useRef(null); - const markdownStyles = useMemo( - () => createMarkdownStyles(theme), - [theme] - ); + const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); const markdownRules = useMemo(() => { return { @@ -360,7 +373,11 @@ export const AssistantMessage = memo(function AssistantMessage({ styles: any, inheritedStyles: any = {} ) => ( - + {children} ), @@ -371,7 +388,11 @@ export const AssistantMessage = memo(function AssistantMessage({ styles: any, inheritedStyles: any = {} ) => ( - + {node.content} ), @@ -402,7 +423,10 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( {content} @@ -415,7 +439,10 @@ export const AssistantMessage = memo(function AssistantMessage({ key={node.key} onPress={() => parsed && onInlinePathPress?.(parsed)} selectable={false} - style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} + style={[ + assistantMessageStylesheet.pathChip, + assistantMessageStylesheet.pathChipText, + ]} > {content} @@ -460,7 +487,9 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( {bullet} - + {children} @@ -781,7 +810,10 @@ export const TodoListCard = memo(function TodoListCard({ [items] ); - const timestampLabel = useMemo(() => formatPlanTimestamp(timestamp), [timestamp]); + const timestampLabel = useMemo( + () => formatPlanTimestamp(timestamp), + [timestamp] + ); const iconColor = theme.colors.background; @@ -791,10 +823,14 @@ export const TodoListCard = memo(function TodoListCard({ Plan - {timestampLabel} + + {timestampLabel} + - {providerLabel} + + {providerLabel} + @@ -809,7 +845,10 @@ export const TodoListCard = memo(function TodoListCard({ ) : ( items.map((item, idx) => ( - + + spinAnim.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }), + [spinAnim] + ); const IconComponent = icon; const iconColor = isError @@ -921,7 +964,9 @@ const ExpandableBadge = memo(function ExpandableBadge({ accessibilityState={hasDetails ? { expanded: isExpanded } : undefined} style={({ pressed }) => [ expandableBadgeStylesheet.pressable, - pressed && hasDetails ? expandableBadgeStylesheet.pressablePressed : null, + pressed && hasDetails + ? expandableBadgeStylesheet.pressablePressed + : null, ]} > @@ -930,7 +975,10 @@ const ExpandableBadge = memo(function ExpandableBadge({ {label} {secondaryLabel ? ( - + {secondaryLabel} ) : ( @@ -948,7 +996,9 @@ const ExpandableBadge = memo(function ExpandableBadge({ ) : null} {detailContent ? ( - {detailContent} + + {detailContent} + ) : null} @@ -963,7 +1013,10 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({ }, })); -export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) { +export const AgentThoughtMessage = memo(function AgentThoughtMessage({ + message, + status = "ready", +}: AgentThoughtMessageProps) { const { theme } = useUnistyles(); const [isExpanded, setIsExpanded] = useState(false); const markdownContent = useMemo(() => message?.trim() ?? "", [message]); @@ -996,7 +1049,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, styles: any, inheritedStyles: any = {} ) => ( - + {children} ), @@ -1007,7 +1064,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, styles: any, inheritedStyles: any = {} ) => ( - + {node.content} ), @@ -1029,7 +1090,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, styles: any, inheritedStyles: any = {} ) => ( - + {node.content} ), @@ -1072,7 +1137,9 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, return ( {bullet} - + {children} @@ -1083,7 +1150,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, const renderDetails = useCallback(() => { if (!markdownContent) { - return No captured thinking; + return ( + + No captured thinking + + ); } return ( @@ -1124,14 +1195,16 @@ const toolKindIcons: Record = { // Derive tool kind from tool name for icon selection function getToolKindFromName(toolName: string): string { const lower = toolName.toLowerCase(); - if (lower === "read" || lower === "read_file" || lower.startsWith("read")) return "read"; - if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit"; + if (lower === "read" || lower === "read_file" || lower.startsWith("read")) + return "read"; + if (lower === "edit" || lower === "write" || lower === "apply_patch") + return "edit"; if (lower === "bash" || lower === "shell") return "execute"; - if (lower === "grep" || lower === "glob" || lower === "web_search") return "search"; + if (lower === "grep" || lower === "glob" || lower === "web_search") + return "search"; return "tool"; } - export const ToolCall = memo(function ToolCall({ toolName, args, @@ -1145,7 +1218,8 @@ export const ToolCall = memo(function ToolCall({ // Check if we're on mobile (use bottom sheet) or desktop (inline expand) const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + UnistylesRuntime.breakpoint === "xs" || + UnistylesRuntime.breakpoint === "sm"; const kind = getToolKindFromName(toolName); const IconComponent = toolKindIcons[kind] || Wrench; @@ -1157,7 +1231,8 @@ export const ToolCall = memo(function ToolCall({ ); // Check if there's any content to display - const hasDetails = args !== undefined || result !== undefined || error !== undefined; + const hasDetails = + args !== undefined || result !== undefined || error !== undefined; // Parse tool call details for inline rendering const { display, errorText } = useToolCallDetails({ args, result, error }); @@ -1184,7 +1259,11 @@ export const ToolCall = memo(function ToolCall({ if (isMobile) return null; return ( - + ); }, [isMobile, display, errorText]); @@ -1196,7 +1275,13 @@ export const ToolCall = memo(function ToolCall({ icon={IconComponent} isExpanded={!isMobile && isExpanded} onToggle={hasDetails ? handleToggle : undefined} - renderDetails={hasDetails && !isMobile ? renderDetails : (hasDetails ? () => null : undefined)} + renderDetails={ + hasDetails && !isMobile + ? renderDetails + : hasDetails + ? () => null + : undefined + } isLoading={status === "executing"} isError={status === "failed"} /> diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 2f1ea87fb..9559acd1a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,4 +1,11 @@ -import { createContext, useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; +import { + createContext, + useRef, + ReactNode, + useCallback, + useEffect, + useMemo, +} from "react"; import { Platform } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket"; @@ -6,7 +13,7 @@ import { useDaemonRequest } from "@/hooks/use-daemon-request"; import { useSessionRpc } from "@/hooks/use-session-rpc"; import { useAudioPlayer } from "@/hooks/use-audio-player"; import { - applyStreamEventWithBuffer, + applyStreamEvent, generateMessageId, hydrateStreamState, } from "@/types/stream"; @@ -17,12 +24,8 @@ import type { WSInboundMessage, SessionOutboundMessage, } from "@server/server/messages"; -import type { - AgentLifecycleStatus, -} from "@server/server/agent/agent-manager"; -import type { - AgentPermissionRequest, -} from "@server/server/agent/agent-sdk-types"; +import type { AgentLifecycleStatus } from "@server/server/agent/agent-manager"; +import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; import { File } from "expo-file-system"; import { useDaemonConnections } from "./daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; @@ -42,13 +45,20 @@ export type { AgentFileExplorerState, } from "@/stores/session-store"; -const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => { +const derivePendingPermissionKey = ( + agentId: string, + request: AgentPermissionRequest +) => { const fallbackId = request.id || - (typeof request.metadata?.id === "string" ? request.metadata.id : undefined) || + (typeof request.metadata?.id === "string" + ? request.metadata.id + : undefined) || request.name || request.title || - `${request.kind}:${JSON.stringify(request.input ?? request.metadata ?? {})}`; + `${request.kind}:${JSON.stringify( + request.input ?? request.metadata ?? {} + )}`; return `${agentId}:${fallbackId}`; }; @@ -81,9 +91,13 @@ const getSessionSnapshotStorageKey = (serverId: string): string => { return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`; }; -async function loadPersistedSessionSnapshot(serverId: string): Promise { +async function loadPersistedSessionSnapshot( + serverId: string +): Promise { try { - const raw = await AsyncStorage.getItem(getSessionSnapshotStorageKey(serverId)); + const raw = await AsyncStorage.getItem( + getSessionSnapshotStorageKey(serverId) + ); if (!raw) { return null; } @@ -93,28 +107,47 @@ async function loadPersistedSessionSnapshot(serverId: string): Promise; setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void; requestGitDiff: (agentId: string) => void; - requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void; + requestDirectoryListing: ( + agentId: string, + path: string, + options?: { recordHistory?: boolean } + ) => void; requestFilePreview: (agentId: string, path: string) => void; - requestFileDownloadToken: (agentId: string, path: string) => Promise; + requestFileDownloadToken: ( + agentId: string, + path: string + ) => Promise; navigateExplorerBack: (agentId: string) => string | null; requestProviderModels: (provider: any, options?: { cwd?: string }) => void; restartServer: (reason?: string) => void; @@ -199,7 +239,11 @@ export interface SessionContextValue { requestId?: string; }) => void; setAgentMode: (agentId: string, modeId: string) => void; - respondToPermission: (agentId: string, requestId: string, response: any) => void; + respondToPermission: ( + agentId: string, + requestId: string, + response: any + ) => void; } const SessionContext = createContext(null); @@ -211,27 +255,45 @@ interface SessionProviderProps { } // SessionProvider: Pure WebSocket message handler that updates Zustand store -export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) { +export function SessionProvider({ + children, + serverUrl, + serverId, +}: SessionProviderProps) { const ws = useWebSocket(serverUrl); const wsIsConnected = ws.isConnected; - const { - updateConnectionStatus, - } = useDaemonConnections(); + const { updateConnectionStatus } = useDaemonConnections(); // Zustand store actions const initializeSession = useSessionStore((state) => state.initializeSession); const clearSession = useSessionStore((state) => state.clearSession); const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); const setMessages = useSessionStore((state) => state.setMessages); - const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage); - const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState); - const setAgentStreamingBuffer = useSessionStore((state) => state.setAgentStreamingBuffer); - const clearAgentStreamingBuffer = useSessionStore((state) => state.clearAgentStreamingBuffer); - const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); - const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents); + const setCurrentAssistantMessage = useSessionStore( + (state) => state.setCurrentAssistantMessage + ); + const setAgentStreamTail = useSessionStore( + (state) => state.setAgentStreamTail + ); + const setAgentStreamHead = useSessionStore( + (state) => state.setAgentStreamHead + ); + const clearAgentStreamHead = useSessionStore( + (state) => state.clearAgentStreamHead + ); + const setInitializingAgents = useSessionStore( + (state) => state.setInitializingAgents + ); + const setHasHydratedAgents = useSessionStore( + (state) => state.setHasHydratedAgents + ); const setAgents = useSessionStore((state) => state.setAgents); - const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); - const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); + const setAgentLastActivity = useSessionStore( + (state) => state.setAgentLastActivity + ); + const setPendingPermissions = useSessionStore( + (state) => state.setPendingPermissions + ); const setGitDiffs = useSessionStore((state) => state.setGitDiffs); const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const setProviderModels = useSessionStore((state) => state.setProviderModels); @@ -250,12 +312,23 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }); const activeAudioGroupsRef = useRef>(new Set()); - const previousAgentStatusRef = useRef>(new Map()); + const previousAgentStatusRef = useRef>( + new Map() + ); const providerModelRequestIdsRef = useRef>(new Map()); - const sendAgentMessageRef = useRef<((agentId: string, message: string, images?: Array<{ uri: string; mimeType?: string }>) => Promise) | null>(null); + const sendAgentMessageRef = useRef< + | (( + agentId: string, + message: string, + images?: Array<{ uri: string; mimeType?: string }> + ) => Promise) + | null + >(null); const hasHydratedSnapshotRef = useRef(false); const hasRequestedInitialSnapshotRef = useRef(false); - const sessionStateTimeoutRef = useRef | null>(null); + const sessionStateTimeoutRef = useRef | null>( + null + ); // Buffer for streaming audio chunks interface AudioChunk { @@ -271,7 +344,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid initializeSession(serverId, ws, audioPlayer); }, [serverId, ws, audioPlayer, initializeSession]); - const updateSessionWebSocket = useSessionStore((state) => state.updateSessionWebSocket); + const updateSessionWebSocket = useSessionStore( + (state) => state.updateSessionWebSocket + ); useEffect(() => { updateSessionWebSocket(serverId, ws); }, [serverId, ws, updateSessionWebSocket]); @@ -292,12 +367,21 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid } if (ws.lastError) { - updateConnectionStatus(serverId, { status: "error", lastError: ws.lastError }); + updateConnectionStatus(serverId, { + status: "error", + lastError: ws.lastError, + }); return; } updateConnectionStatus(serverId, { status: "offline" }); - }, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]); + }, [ + serverId, + updateConnectionStatus, + ws.isConnected, + ws.isConnecting, + ws.lastError, + ]); // If the socket drops mid-initialization, clear pending flags useEffect(() => { @@ -423,16 +507,22 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }), matchResponse: (message, context) => message.payload.status === "agent_initialized" && - (message.payload as { agentId?: string }).agentId === context.params?.agentId && - (message.payload as { requestId?: string }).requestId === context.requestId, + (message.payload as { agentId?: string }).agentId === + context.params?.agentId && + (message.payload as { requestId?: string }).requestId === + context.requestId, getRequestKey: (params) => params?.agentId ?? "default", selectData: (message) => ({ agentId: (message.payload as { agentId?: string }).agentId ?? "", - lifecycle: (message.payload as { agentStatus?: AgentLifecycleStatus }).agentStatus, + lifecycle: (message.payload as { agentStatus?: AgentLifecycleStatus }) + .agentStatus, }), extractError: (message) => message.payload.status === "error" - ? new Error((message.payload as { message?: string }).message ?? "Refresh failed") + ? new Error( + (message.payload as { message?: string }).message ?? + "Refresh failed" + ) : null, timeoutMs: 15000, keepPreviousData: false, @@ -553,7 +643,12 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid let retryCount = 0; const requestSessionState = () => { - console.log(`[Session] Requesting session_state (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`, { serverId }); + console.log( + `[Session] Requesting session_state (attempt ${retryCount + 1}/${ + MAX_RETRIES + 1 + })`, + { serverId } + ); ws.send({ type: "session", @@ -570,23 +665,29 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid sessionStateTimeoutRef.current = setTimeout(() => { if (retryCount < MAX_RETRIES) { retryCount++; - console.warn(`[Session] session_state timeout, retrying in ${RETRY_DELAY_MS}ms`, { - serverId, - attempt: retryCount, - maxRetries: MAX_RETRIES, - }); + console.warn( + `[Session] session_state timeout, retrying in ${RETRY_DELAY_MS}ms`, + { + serverId, + attempt: retryCount, + maxRetries: MAX_RETRIES, + } + ); setTimeout(() => { requestSessionState(); }, RETRY_DELAY_MS); } else { - console.error(`[Session] session_state failed after ${MAX_RETRIES} retries`, { serverId }); + console.error( + `[Session] session_state failed after ${MAX_RETRIES} retries`, + { serverId } + ); setHasHydratedAgents(serverId, true); updateConnectionStatus(serverId, { status: "online", lastOnlineAt: new Date().toISOString(), - sessionReady: true + sessionReady: true, }); } }, TIMEOUT_MS); @@ -600,7 +701,13 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid sessionStateTimeoutRef.current = null; } }; - }, [wsIsConnected, ws, serverId, setHasHydratedAgents, updateConnectionStatus]); + }, [ + wsIsConnected, + ws, + serverId, + setHasHydratedAgents, + updateConnectionStatus, + ]); // WebSocket message handlers - directly update Zustand store useEffect(() => { @@ -616,7 +723,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const { agents: agentsList } = message.payload; - console.log("[Session] ✅ Received session_state:", agentsList.length, "agents"); + console.log( + "[Session] ✅ Received session_state:", + agentsList.length, + "agents" + ); setInitializingAgents(serverId, new Map()); const agents = new Map(); @@ -641,12 +752,14 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid } setPendingPermissions(serverId, pendingPermissions); - setAgentStreamState(serverId, (prev) => { + setAgentStreamTail(serverId, (prev) => { if (prev.size === 0) { return prev; } - const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id)); + const validAgentIds = new Set( + agentsList.map((snapshot) => snapshot.id) + ); let changed = false; const next = new Map(prev); @@ -659,12 +772,14 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return changed ? next : prev; }); - setAgentStreamingBuffer(serverId, (prev) => { + setAgentStreamHead(serverId, (prev) => { if (prev.size === 0) { return prev; } - const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id)); + const validAgentIds = new Set( + agentsList.map((snapshot) => snapshot.id) + ); let changed = false; const next = new Map(prev); @@ -682,7 +797,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return prev; } - const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id)); + const validAgentIds = new Set( + agentsList.map((snapshot) => snapshot.id) + ); let changed = false; const next = new Map(prev); @@ -697,7 +814,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }); void persistSessionSnapshot(serverId, { agents: agentsList }); setHasHydratedAgents(serverId, true); - updateConnectionStatus(serverId, { status: "online", lastOnlineAt: new Date().toISOString(), sessionReady: true }); + updateConnectionStatus(serverId, { + status: "online", + lastOnlineAt: new Date().toISOString(), + sessionReady: true, + }); }); const unsubAgentState = ws.on("agent_state", (message) => { @@ -737,7 +858,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const queue = session?.queuedMessages.get(agent.id); if (queue && queue.length > 0) { const [next, ...rest] = queue; - console.log("[Session] Flushing queued message for agent:", agent.id, next.text); + console.log( + "[Session] Flushing queued message for agent:", + agent.id, + next.text + ); if (sendAgentMessageRef.current) { void sendAgentMessageRef.current(agent.id, next.text, next.images); } @@ -756,31 +881,31 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const { agentId, event, timestamp } = message.payload; const parsedTimestamp = new Date(timestamp); - console.log("[Session] agent_stream", { agentId, eventType: event.type }); + console.log("[Session] agent_stream", { agentId, event, timestamp }); const session = useSessionStore.getState().sessions[serverId]; - const currentStream = session?.agentStreamState.get(agentId) ?? []; - const currentBuffer = session?.agentStreamingBuffer.get(agentId) ?? null; - const { stream, buffer, changedStream, changedBuffer } = applyStreamEventWithBuffer({ - state: currentStream, - buffer: currentBuffer, + const currentTail = session?.agentStreamTail.get(agentId) ?? []; + const currentHead = session?.agentStreamHead.get(agentId) ?? []; + const { tail, head, changedTail, changedHead } = applyStreamEvent({ + tail: currentTail, + head: currentHead, event: event as AgentStreamEventPayload, timestamp: parsedTimestamp, }); - if (changedStream) { - setAgentStreamState(serverId, (prev) => { + if (changedTail) { + setAgentStreamTail(serverId, (prev) => { const next = new Map(prev); - next.set(agentId, stream); + next.set(agentId, tail); return next; }); } - if (changedBuffer) { - setAgentStreamingBuffer(serverId, (prev) => { + if (changedHead) { + setAgentStreamHead(serverId, (prev) => { const next = new Map(prev); - if (buffer) { - next.set(agentId, buffer); + if (head.length > 0) { + next.set(agentId, head); } else { next.delete(agentId); } @@ -803,38 +928,41 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid // on status changes, which is sufficient for sorting and display purposes. }); - const unsubAgentStreamSnapshot = ws.on("agent_stream_snapshot", (message) => { - if (message.type !== "agent_stream_snapshot") return; - const { agentId, events } = message.payload; + const unsubAgentStreamSnapshot = ws.on( + "agent_stream_snapshot", + (message) => { + if (message.type !== "agent_stream_snapshot") return; + const { agentId, events } = message.payload; - console.log("[Session] agent_stream_snapshot", { - agentId, - eventCount: events.length, - }); + console.log("[Session] agent_stream_snapshot", { + agentId, + eventCount: events.length, + }); - const hydrated = hydrateStreamState( - events.map(({ event, timestamp }) => ({ - event: event as AgentStreamEventPayload, - timestamp: new Date(timestamp), - })) - ); + const hydrated = hydrateStreamState( + events.map(({ event, timestamp }) => ({ + event: event as AgentStreamEventPayload, + timestamp: new Date(timestamp), + })) + ); - setAgentStreamState(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, hydrated); - return next; - }); - clearAgentStreamingBuffer(serverId, agentId); + setAgentStreamTail(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, hydrated); + return next; + }); + clearAgentStreamHead(serverId, agentId); - setInitializingAgents(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.set(agentId, false); - return next; - }); - }); + setInitializingAgents(serverId, (prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + } + ); const unsubStatus = ws.on("status", (message) => { if (message.type !== "status") return; @@ -857,40 +985,59 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid } }); - const unsubPermissionRequest = ws.on("agent_permission_request", (message) => { - if (message.type !== "agent_permission_request") return; - const { agentId, request } = message.payload; + const unsubPermissionRequest = ws.on( + "agent_permission_request", + (message) => { + if (message.type !== "agent_permission_request") return; + const { agentId, request } = message.payload; - console.log("[Session] Permission request:", request.id, "for agent:", agentId); + console.log( + "[Session] Permission request:", + request.id, + "for agent:", + agentId + ); - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - const key = derivePendingPermissionKey(agentId, request); - next.set(key, { key, agentId, request }); - return next; - }); - }); + setPendingPermissions(serverId, (prev) => { + const next = new Map(prev); + const key = derivePendingPermissionKey(agentId, request); + next.set(key, { key, agentId, request }); + return next; + }); + } + ); - const unsubPermissionResolved = ws.on("agent_permission_resolved", (message) => { - if (message.type !== "agent_permission_resolved") return; - const { requestId, agentId } = message.payload; + const unsubPermissionResolved = ws.on( + "agent_permission_resolved", + (message) => { + if (message.type !== "agent_permission_resolved") return; + const { requestId, agentId } = message.payload; - console.log("[Session] Permission resolved:", requestId, "for agent:", agentId); + console.log( + "[Session] Permission resolved:", + requestId, + "for agent:", + agentId + ); - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - const derivedKey = `${agentId}:${requestId}`; - if (!next.delete(derivedKey)) { - for (const [key, pending] of next.entries()) { - if (pending.agentId === agentId && pending.request.id === requestId) { - next.delete(key); - break; + setPendingPermissions(serverId, (prev) => { + const next = new Map(prev); + const derivedKey = `${agentId}:${requestId}`; + if (!next.delete(derivedKey)) { + for (const [key, pending] of next.entries()) { + if ( + pending.agentId === agentId && + pending.request.id === requestId + ) { + next.delete(key); + break; + } } } - } - return next; - }); - }); + return next; + }); + } + ); const unsubAudioOutput = ws.on("audio_output", async (message) => { if (message.type !== "audio_output") return; @@ -915,15 +1062,19 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }); if (!isFinalChunk) { - console.log(`[Session] Buffered chunk ${chunkIndex} for group ${playbackGroupId}`); + console.log( + `[Session] Buffered chunk ${chunkIndex} for group ${playbackGroupId}` + ); return; } - console.log(`[Session] Received final chunk for group ${playbackGroupId}, total chunks: ${buffer.length}`); + console.log( + `[Session] Received final chunk for group ${playbackGroupId}, total chunks: ${buffer.length}` + ); buffer.sort((a, b) => a.chunkIndex - b.chunkIndex); let playbackFailed = false; - const chunkIds = buffer.map(chunk => chunk.id); + const chunkIds = buffer.map((chunk) => chunk.id); try { const mimeType = @@ -949,7 +1100,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid offset += chunk.length; } - console.log(`[Session] Playing concatenated audio: ${buffer.length} chunks, ${totalSize} bytes`); + console.log( + `[Session] Playing concatenated audio: ${buffer.length} chunks, ${totalSize} bytes` + ); const audioBlob = { type: mimeType, @@ -1108,7 +1261,10 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const unsubChunk = ws.on("assistant_chunk", (message) => { if (message.type !== "assistant_chunk") return; - setCurrentAssistantMessage(serverId, (prev) => prev + message.payload.chunk); + setCurrentAssistantMessage( + serverId, + (prev) => prev + message.payload.chunk + ); }); const unsubTranscription = ws.on("transcription_result", (message) => { @@ -1117,7 +1273,9 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const transcriptText = message.payload.text.trim(); if (!transcriptText) { - console.log("[Session] Empty transcription (false positive) - ignoring"); + console.log( + "[Session] Empty transcription (false positive) - ignoring" + ); } else { console.log("[Session] Transcription received - stopping playback"); audioPlayer.stop(); @@ -1132,8 +1290,10 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid if (message.type !== "list_provider_models_response") { return; } - const { provider, models, error, fetchedAt, requestId } = message.payload; - const latestRequestId = providerModelRequestIdsRef.current.get(provider); + const { provider, models, error, fetchedAt, requestId } = + message.payload; + const latestRequestId = + providerModelRequestIdsRef.current.get(provider); if (latestRequestId && requestId && requestId !== latestRequestId) { return; } @@ -1182,7 +1342,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid }; }); - setAgentStreamState(serverId, (prev) => { + setAgentStreamTail(serverId, (prev) => { if (!prev.has(agentId)) { return prev; } @@ -1190,7 +1350,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid next.delete(agentId); return next; }); - clearAgentStreamingBuffer(serverId, agentId); + clearAgentStreamHead(serverId, agentId); // Remove draft input saveDraftInput(agentId, { text: "", images: [] }); @@ -1250,26 +1410,46 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid unsubProviderModels(); unsubAgentDeleted(); }; - }, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setAgentStreamingBuffer, clearAgentStreamingBuffer, setInitializingAgents, setAgents, setAgentLastActivity, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]); + }, [ + ws, + audioPlayer, + serverId, + setIsPlayingAudio, + setMessages, + setCurrentAssistantMessage, + setAgentStreamTail, + setAgentStreamHead, + clearAgentStreamHead, + setInitializingAgents, + setAgents, + setAgentLastActivity, + setPendingPermissions, + setGitDiffs, + setFileExplorer, + setProviderModels, + setHasHydratedAgents, + updateConnectionStatus, + getSession, + saveDraftInput, + ]); - const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => { - console.log("[Session] initializeAgent called", { agentId, requestId }); - setInitializingAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, true); - return next; - }); + const initializeAgent = useCallback( + ({ agentId, requestId }: { agentId: string; requestId?: string }) => { + console.log("[Session] initializeAgent called", { agentId, requestId }); + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, true); + return next; + }); - setAgentStreamState(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, []); - return next; - }); - clearAgentStreamingBuffer(serverId, agentId); + setAgentStreamTail(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, []); + return next; + }); + clearAgentStreamHead(serverId, agentId); - initializeAgentRpc - .send({ agentId }) - .catch((error) => { + initializeAgentRpc.send({ agentId }).catch((error) => { console.warn("[Session] initializeAgent failed", { agentId, error }); setInitializingAgents(serverId, (prev) => { const next = new Map(prev); @@ -1277,404 +1457,514 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return next; }); }); - }, [serverId, initializeAgentRpc, setAgentStreamState, setInitializingAgents, clearAgentStreamingBuffer]); + }, + [ + serverId, + initializeAgentRpc, + setAgentStreamTail, + setInitializingAgents, + clearAgentStreamHead, + ] + ); - const refreshAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => { - setInitializingAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, true); - return next; - }); - - setAgentStreamState(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, []); - return next; - }); - clearAgentStreamingBuffer(serverId, agentId); - - refreshAgentRequest - .execute({ agentId }, { requestKeyOverride: agentId, dedupe: false }) - .catch((error) => { - console.warn("[Session] refreshAgent failed", { agentId, error }); - setInitializingAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, false); - return next; - }); + const refreshAgent = useCallback( + ({ agentId, requestId }: { agentId: string; requestId?: string }) => { + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, true); + return next; }); - }, [serverId, refreshAgentRequest, setAgentStreamState, setInitializingAgents, clearAgentStreamingBuffer]); - const requestProviderModels = useCallback((provider: any, options?: { cwd?: string }) => { - const requestId = generateMessageId(); - providerModelRequestIdsRef.current.set(provider, requestId); - setProviderModels(serverId, (prev) => { - const next = new Map(prev); - const current = - prev.get(provider) ?? { + setAgentStreamTail(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, []); + return next; + }); + clearAgentStreamHead(serverId, agentId); + + refreshAgentRequest + .execute({ agentId }, { requestKeyOverride: agentId, dedupe: false }) + .catch((error) => { + console.warn("[Session] refreshAgent failed", { agentId, error }); + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + }); + }, + [ + serverId, + refreshAgentRequest, + setAgentStreamTail, + setInitializingAgents, + clearAgentStreamHead, + ] + ); + + const requestProviderModels = useCallback( + (provider: any, options?: { cwd?: string }) => { + const requestId = generateMessageId(); + providerModelRequestIdsRef.current.set(provider, requestId); + setProviderModels(serverId, (prev) => { + const next = new Map(prev); + const current = prev.get(provider) ?? { models: null, fetchedAt: null, error: null, isLoading: false, }; - next.set(provider, { - ...current, - isLoading: true, - error: null, + next.set(provider, { + ...current, + isLoading: true, + error: null, + }); + return next; }); - return next; - }); - const msg: WSInboundMessage = { - type: "session", - message: { - type: "list_provider_models_request", - provider, - ...(options?.cwd ? { cwd: options.cwd } : {}), - requestId, - }, - }; - ws.send(msg); - }, [serverId, ws, setProviderModels]); + const msg: WSInboundMessage = { + type: "session", + message: { + type: "list_provider_models_request", + provider, + ...(options?.cwd ? { cwd: options.cwd } : {}), + requestId, + }, + }; + ws.send(msg); + }, + [serverId, ws, setProviderModels] + ); - const encodeImages = useCallback(async ( - images?: Array<{ uri: string; mimeType?: string }> - ) => { - if (!images || images.length === 0) { - return undefined; - } - const encodedImages = await Promise.all( - images.map(async ({ uri, mimeType }) => { - try { - const data = await (async () => { - if (Platform.OS === "web") { - if (uri.startsWith("data:")) { - const [, base64] = uri.split(",", 2); - if (!base64) { - throw new Error("Malformed data URI for image."); + const encodeImages = useCallback( + async (images?: Array<{ uri: string; mimeType?: string }>) => { + if (!images || images.length === 0) { + return undefined; + } + const encodedImages = await Promise.all( + images.map(async ({ uri, mimeType }) => { + try { + const data = await (async () => { + if (Platform.OS === "web") { + if (uri.startsWith("data:")) { + const [, base64] = uri.split(",", 2); + if (!base64) { + throw new Error("Malformed data URI for image."); + } + return base64; } + const response = await fetch(uri); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + const blob = await response.blob(); + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== "string") { + reject(new Error("Unexpected FileReader result type.")); + return; + } + const [, resultBase64] = reader.result.split(",", 2); + if (!resultBase64) { + reject(new Error("Failed to read image data as base64.")); + return; + } + resolve(resultBase64); + }; + reader.onerror = () => { + reject( + reader.error ?? new Error("Failed to read image data.") + ); + }; + reader.readAsDataURL(blob); + }); return base64; } - const response = await fetch(uri); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.status}`); - } - const blob = await response.blob(); - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result !== "string") { - reject(new Error("Unexpected FileReader result type.")); - return; - } - const [, resultBase64] = reader.result.split(",", 2); - if (!resultBase64) { - reject(new Error("Failed to read image data as base64.")); - return; - } - resolve(resultBase64); - }; - reader.onerror = () => { - reject(reader.error ?? new Error("Failed to read image data.")); - }; - reader.readAsDataURL(blob); - }); - return base64; - } - const file = new File(uri); - return await file.base64(); - })(); - return { - data, - mimeType: mimeType ?? "image/jpeg", - }; - } catch (error) { - console.error("[Session] Failed to convert image:", error); - return null; - } - }) - ); - const validImages = encodedImages.filter( - (entry): entry is { data: string; mimeType: string } => entry !== null - ); - return validImages.length > 0 ? validImages : undefined; - }, []); + const file = new File(uri); + return await file.base64(); + })(); + return { + data, + mimeType: mimeType ?? "image/jpeg", + }; + } catch (error) { + console.error("[Session] Failed to convert image:", error); + return null; + } + }) + ); + const validImages = encodedImages.filter( + (entry): entry is { data: string; mimeType: string } => entry !== null + ); + return validImages.length > 0 ? validImages : undefined; + }, + [] + ); - const sendAgentMessage = useCallback(async ( - agentId: string, - message: string, - images?: Array<{ uri: string; mimeType?: string }> - ) => { - const messageId = generateMessageId(); + const sendAgentMessage = useCallback( + async ( + agentId: string, + message: string, + images?: Array<{ uri: string; mimeType?: string }> + ) => { + const messageId = generateMessageId(); - setAgentStreamState(serverId, (prev) => { - const currentStream = prev.get(agentId) || []; - const nextItem: any = { - kind: "user_message", - id: messageId, - text: message, - timestamp: new Date(), - }; - const updated = new Map(prev); - updated.set(agentId, [...currentStream, nextItem]); - return updated; - }); + setAgentStreamTail(serverId, (prev) => { + const currentStream = prev.get(agentId) || []; + const nextItem: any = { + kind: "user_message", + id: messageId, + text: message, + timestamp: new Date(), + }; + const updated = new Map(prev); + updated.set(agentId, [...currentStream, nextItem]); + return updated; + }); - const imagesData = await encodeImages(images); - - const msg: WSInboundMessage = { - type: "session", - message: { - type: "send_agent_message", - agentId, - text: message, - messageId, - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - }, - }; - ws.send(msg); - }, [encodeImages, serverId, ws, setAgentStreamState]); - - // Keep the ref updated so the agent_state handler can call it - sendAgentMessageRef.current = sendAgentMessage; - - const cancelAgentRun = useCallback((agentId: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "cancel_agent_request", - agentId, - }, - }; - ws.send(msg); - }, [ws]); - - const deleteAgent = useCallback((agentId: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "delete_agent_request", - agentId, - }, - }; - ws.send(msg); - }, [ws]); - - const restartServer = useCallback((reason?: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "restart_server_request", - ...(reason && reason.trim().length > 0 ? { reason } : {}), - }, - }; - ws.send(msg); - }, [ws]); - - const sendAgentAudio = useCallback(async ( - agentId: string | undefined, - audioBlob: Blob, - requestId?: string, - options?: { mode?: "transcribe_only" | "auto_run" } - ) => { - try { - const isSocketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected; - if (!isSocketConnected) { - throw new Error("WebSocket is disconnected"); - } - const arrayBuffer = await audioBlob.arrayBuffer(); - const bytes = new Uint8Array(arrayBuffer); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64Audio = btoa(binary); - - const deriveFormat = (mimeType: string | undefined): string => { - if (!mimeType || mimeType.length === 0) { - return "webm"; - } - const slashIndex = mimeType.indexOf("/"); - let formatPart = slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType; - const semicolonIndex = formatPart.indexOf(";"); - if (semicolonIndex >= 0) { - formatPart = formatPart.slice(0, semicolonIndex); - } - return formatPart.trim().length > 0 ? formatPart.trim() : "webm"; - }; - - const format = deriveFormat(audioBlob.type); + const imagesData = await encodeImages(images); const msg: WSInboundMessage = { type: "session", message: { - type: "send_agent_audio", - ...(agentId ? { agentId } : {}), - audio: base64Audio, - format, - isLast: true, - requestId, - ...(options?.mode ? { mode: options.mode } : {}), + type: "send_agent_message", + agentId, + text: message, + messageId, + ...(imagesData && imagesData.length > 0 + ? { images: imagesData } + : {}), }, }; ws.send(msg); + }, + [encodeImages, serverId, ws, setAgentStreamTail] + ); - console.log("[Session] Sent audio:", agentId ?? "(no agent)", format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : ""); - } catch (error) { - console.error("[Session] Failed to send audio:", error); - throw error; - } - }, [ws]); + // Keep the ref updated so the agent_state handler can call it + sendAgentMessageRef.current = sendAgentMessage; - const createAgent = useCallback(async ({ config, initialPrompt, images, git, worktreeName, requestId }: { config: any; initialPrompt: string; images?: Array<{ uri: string; mimeType?: string }>; git?: any; worktreeName?: string; requestId?: string }) => { - console.log("[Session] createAgent called with images:", images?.length ?? 0, images); - const trimmedPrompt = initialPrompt.trim(); - let imagesData: Array<{ data: string; mimeType: string }> | undefined; - try { - imagesData = await encodeImages(images); - console.log("[Session] encodeImages result:", imagesData?.length ?? 0, imagesData?.map(img => ({ dataLength: img.data?.length ?? 0, mimeType: img.mimeType }))); - } catch (error) { - console.error("[Session] Failed to prepare images for agent creation:", error); - } - const msg: WSInboundMessage = { - type: "session", - message: { - type: "create_agent_request", - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }, - }; - console.log("[Session] createAgent message has images:", 'images' in msg.message, (msg.message as any).images?.length); - ws.send(msg); - }, [encodeImages, ws]); + const cancelAgentRun = useCallback( + (agentId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "cancel_agent_request", + agentId, + }, + }; + ws.send(msg); + }, + [ws] + ); - const setAgentMode = useCallback((agentId: string, modeId: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "set_agent_mode", - agentId, - modeId, - }, - }; - ws.send(msg); - }, [ws]); + const deleteAgent = useCallback( + (agentId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "delete_agent_request", + agentId, + }, + }; + ws.send(msg); + }, + [ws] + ); - const respondToPermission = useCallback((agentId: string, requestId: string, response: any) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "agent_permission_response", - agentId, - requestId, - response, - }, - }; - ws.send(msg); - }, [ws]); + const restartServer = useCallback( + (reason?: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "restart_server_request", + ...(reason && reason.trim().length > 0 ? { reason } : {}), + }, + }; + ws.send(msg); + }, + [ws] + ); - const setVoiceDetectionFlags = useCallback((isDetecting: boolean, isSpeaking: boolean) => { - isDetectingRef.current = isDetecting; - isSpeakingRef.current = isSpeaking; - }, []); + const sendAgentAudio = useCallback( + async ( + agentId: string | undefined, + audioBlob: Blob, + requestId?: string, + options?: { mode?: "transcribe_only" | "auto_run" } + ) => { + try { + const isSocketConnected = ws.getConnectionState + ? ws.getConnectionState().isConnected + : ws.isConnected; + if (!isSocketConnected) { + throw new Error("WebSocket is disconnected"); + } + const arrayBuffer = await audioBlob.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64Audio = btoa(binary); - const requestGitDiff = useCallback((agentId: string) => { - gitDiffRequest - .execute({ agentId }) - .then((result) => { - setGitDiffs(serverId, (prev) => new Map(prev).set(result.agentId, result.diff)); - }) - .catch((error) => { - setGitDiffs(serverId, (prev) => new Map(prev).set(agentId, `Error: ${error.message}`)); - }); - }, [serverId, gitDiffRequest, setGitDiffs]); + const deriveFormat = (mimeType: string | undefined): string => { + if (!mimeType || mimeType.length === 0) { + return "webm"; + } + const slashIndex = mimeType.indexOf("/"); + let formatPart = + slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType; + const semicolonIndex = formatPart.indexOf(";"); + if (semicolonIndex >= 0) { + formatPart = formatPart.slice(0, semicolonIndex); + } + return formatPart.trim().length > 0 ? formatPart.trim() : "webm"; + }; - const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => { - const normalizedPath = path && path.length > 0 ? path : "."; - const shouldRecordHistory = options?.recordHistory ?? true; + const format = deriveFormat(audioBlob.type); - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: true, - lastError: null, - pendingRequest: { path: normalizedPath, mode: "list" }, - currentPath: normalizedPath, - history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history, - lastVisitedPath: normalizedPath, - })); + const msg: WSInboundMessage = { + type: "session", + message: { + type: "send_agent_audio", + ...(agentId ? { agentId } : {}), + audio: base64Audio, + format, + isLast: true, + requestId, + ...(options?.mode ? { mode: options.mode } : {}), + }, + }; + ws.send(msg); - directoryListingRequest - .execute({ agentId, path: normalizedPath }) - .then((payload) => { - updateExplorerState(agentId, (state: any) => { - const nextState: any = { + console.log( + "[Session] Sent audio:", + agentId ?? "(no agent)", + format, + audioBlob.size, + "bytes", + requestId ? `(requestId: ${requestId})` : "" + ); + } catch (error) { + console.error("[Session] Failed to send audio:", error); + throw error; + } + }, + [ws] + ); + + const createAgent = useCallback( + async ({ + config, + initialPrompt, + images, + git, + worktreeName, + requestId, + }: { + config: any; + initialPrompt: string; + images?: Array<{ uri: string; mimeType?: string }>; + git?: any; + worktreeName?: string; + requestId?: string; + }) => { + console.log( + "[Session] createAgent called with images:", + images?.length ?? 0, + images + ); + const trimmedPrompt = initialPrompt.trim(); + let imagesData: Array<{ data: string; mimeType: string }> | undefined; + try { + imagesData = await encodeImages(images); + console.log( + "[Session] encodeImages result:", + imagesData?.length ?? 0, + imagesData?.map((img) => ({ + dataLength: img.data?.length ?? 0, + mimeType: img.mimeType, + })) + ); + } catch (error) { + console.error( + "[Session] Failed to prepare images for agent creation:", + error + ); + } + const msg: WSInboundMessage = { + type: "session", + message: { + type: "create_agent_request", + config, + ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), + ...(imagesData && imagesData.length > 0 + ? { images: imagesData } + : {}), + ...(git ? { git } : {}), + ...(worktreeName ? { worktreeName } : {}), + ...(requestId ? { requestId } : {}), + }, + }; + console.log( + "[Session] createAgent message has images:", + "images" in msg.message, + (msg.message as any).images?.length + ); + ws.send(msg); + }, + [encodeImages, ws] + ); + + const setAgentMode = useCallback( + (agentId: string, modeId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "set_agent_mode", + agentId, + modeId, + }, + }; + ws.send(msg); + }, + [ws] + ); + + const respondToPermission = useCallback( + (agentId: string, requestId: string, response: any) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "agent_permission_response", + agentId, + requestId, + response, + }, + }; + ws.send(msg); + }, + [ws] + ); + + const setVoiceDetectionFlags = useCallback( + (isDetecting: boolean, isSpeaking: boolean) => { + isDetectingRef.current = isDetecting; + isSpeakingRef.current = isSpeaking; + }, + [] + ); + + const requestGitDiff = useCallback( + (agentId: string) => { + gitDiffRequest + .execute({ agentId }) + .then((result) => { + setGitDiffs(serverId, (prev) => + new Map(prev).set(result.agentId, result.diff) + ); + }) + .catch((error) => { + setGitDiffs(serverId, (prev) => + new Map(prev).set(agentId, `Error: ${error.message}`) + ); + }); + }, + [serverId, gitDiffRequest, setGitDiffs] + ); + + const requestDirectoryListing = useCallback( + (agentId: string, path: string, options?: { recordHistory?: boolean }) => { + const normalizedPath = path && path.length > 0 ? path : "."; + const shouldRecordHistory = options?.recordHistory ?? true; + + updateExplorerState(agentId, (state: any) => ({ + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: normalizedPath, mode: "list" }, + currentPath: normalizedPath, + history: shouldRecordHistory + ? pushHistory(state.history, normalizedPath) + : state.history, + lastVisitedPath: normalizedPath, + })); + + directoryListingRequest + .execute({ agentId, path: normalizedPath }) + .then((payload) => { + updateExplorerState(agentId, (state: any) => { + const nextState: any = { + ...state, + isLoading: false, + lastError: payload.error ?? null, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!payload.error && payload.directory) { + const directories = new Map(state.directories); + directories.set(payload.directory.path, payload.directory); + nextState.directories = directories; + } + + return nextState; + }); + }) + .catch((error) => { + updateExplorerState(agentId, (state: any) => ({ ...state, isLoading: false, - lastError: payload.error ?? null, + lastError: error.message, pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!payload.error && payload.directory) { - const directories = new Map(state.directories); - directories.set(payload.directory.path, payload.directory); - nextState.directories = directories; - } - - return nextState; + })); }); - }) - .catch((error) => { - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: false, - lastError: error.message, - pendingRequest: null, - })); - }); - }, [directoryListingRequest, updateExplorerState]); + }, + [directoryListingRequest, updateExplorerState] + ); - const requestFilePreview = useCallback((agentId: string, path: string) => { - const normalizedPath = path && path.length > 0 ? path : "."; - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: true, - pendingRequest: { path: normalizedPath, mode: "file" }, - })); + const requestFilePreview = useCallback( + (agentId: string, path: string) => { + const normalizedPath = path && path.length > 0 ? path : "."; + updateExplorerState(agentId, (state: any) => ({ + ...state, + isLoading: true, + pendingRequest: { path: normalizedPath, mode: "file" }, + })); - filePreviewRequest - .execute({ agentId, path: normalizedPath }) - .then((payload) => { - updateExplorerState(agentId, (state: any) => { - const nextState: any = { + filePreviewRequest + .execute({ agentId, path: normalizedPath }) + .then((payload) => { + updateExplorerState(agentId, (state: any) => { + const nextState: any = { + ...state, + isLoading: false, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!payload.error && payload.file) { + const files = new Map(state.files); + files.set(payload.file.path, payload.file); + nextState.files = files; + } + + return nextState; + }); + }) + .catch((error) => { + updateExplorerState(agentId, (state: any) => ({ ...state, isLoading: false, pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!payload.error && payload.file) { - const files = new Map(state.files); - files.set(payload.file.path, payload.file); - nextState.files = files; - } - - return nextState; + })); }); - }) - .catch((error) => { - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: false, - pendingRequest: null, - })); - }); - }, [filePreviewRequest, updateExplorerState]); + }, + [filePreviewRequest, updateExplorerState] + ); const requestFileDownloadToken = useCallback( (agentId: string, path: string) => { @@ -1683,64 +1973,67 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid [fileDownloadTokenRequest] ); - const navigateExplorerBack = useCallback((agentId: string) => { - let targetPath: string | null = null; + const navigateExplorerBack = useCallback( + (agentId: string) => { + let targetPath: string | null = null; - updateExplorerState(agentId, (state: any) => { - if (!state.history || state.history.length <= 1) { - return state; + updateExplorerState(agentId, (state: any) => { + if (!state.history || state.history.length <= 1) { + return state; + } + + const nextHistory = state.history.slice(0, -1); + targetPath = nextHistory[nextHistory.length - 1] ?? "."; + + return { + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: targetPath, mode: "list" }, + currentPath: targetPath, + history: nextHistory, + lastVisitedPath: targetPath, + }; + }); + + if (!targetPath) { + return null; } - const nextHistory = state.history.slice(0, -1); - targetPath = nextHistory[nextHistory.length - 1] ?? "."; + directoryListingRequest + .execute({ agentId, path: targetPath }) + .then((payload) => { + updateExplorerState(agentId, (state: any) => { + const nextState: any = { + ...state, + isLoading: false, + lastError: payload.error ?? null, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; - return { - ...state, - isLoading: true, - lastError: null, - pendingRequest: { path: targetPath, mode: "list" }, - currentPath: targetPath, - history: nextHistory, - lastVisitedPath: targetPath, - }; - }); + if (!payload.error && payload.directory) { + const directories = new Map(state.directories); + directories.set(payload.directory.path, payload.directory); + nextState.directories = directories; + } - if (!targetPath) { - return null; - } - - directoryListingRequest - .execute({ agentId, path: targetPath }) - .then((payload) => { - updateExplorerState(agentId, (state: any) => { - const nextState: any = { + return nextState; + }); + }) + .catch((error) => { + updateExplorerState(agentId, (state: any) => ({ ...state, isLoading: false, - lastError: payload.error ?? null, + lastError: error.message, pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!payload.error && payload.directory) { - const directories = new Map(state.directories); - directories.set(payload.directory.path, payload.directory); - nextState.directories = directories; - } - - return nextState; + })); }); - }) - .catch((error) => { - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: false, - lastError: error.message, - pendingRequest: null, - })); - }); - return targetPath; - }, [directoryListingRequest, updateExplorerState]); + return targetPath; + }, + [directoryListingRequest, updateExplorerState] + ); const refreshSession = useCallback(() => { console.log(`[Session] Manual refresh requested for ${serverId}`); @@ -1812,54 +2105,55 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid // Sync imperative methods to Zustand store so components can access them via selectors // Memoize the methods object to avoid infinite re-renders (object reference must be stable) const setSessionMethods = useSessionStore((state) => state.setSessionMethods); - const methods = useMemo(() => ({ - setVoiceDetectionFlags, - requestGitDiff, - requestDirectoryListing, - requestFilePreview, - requestFileDownloadToken, - navigateExplorerBack, - requestProviderModels, - restartServer, - initializeAgent, - refreshAgent, - refreshSession, - cancelAgentRun, - sendAgentMessage, - sendAgentAudio, - deleteAgent, - createAgent, - setAgentMode, - respondToPermission, - }), [ - setVoiceDetectionFlags, - requestGitDiff, - requestDirectoryListing, - requestFilePreview, - requestFileDownloadToken, - navigateExplorerBack, - requestProviderModels, - restartServer, - initializeAgent, - refreshAgent, - refreshSession, - cancelAgentRun, - sendAgentMessage, - sendAgentAudio, - deleteAgent, - createAgent, - setAgentMode, - respondToPermission, - ]); + const methods = useMemo( + () => ({ + setVoiceDetectionFlags, + requestGitDiff, + requestDirectoryListing, + requestFilePreview, + requestFileDownloadToken, + navigateExplorerBack, + requestProviderModels, + restartServer, + initializeAgent, + refreshAgent, + refreshSession, + cancelAgentRun, + sendAgentMessage, + sendAgentAudio, + deleteAgent, + createAgent, + setAgentMode, + respondToPermission, + }), + [ + setVoiceDetectionFlags, + requestGitDiff, + requestDirectoryListing, + requestFilePreview, + requestFileDownloadToken, + navigateExplorerBack, + requestProviderModels, + restartServer, + initializeAgent, + refreshAgent, + refreshSession, + cancelAgentRun, + sendAgentMessage, + sendAgentAudio, + deleteAgent, + createAgent, + setAgentMode, + respondToPermission, + ] + ); useEffect(() => { setSessionMethods(serverId, methods); }, [serverId, setSessionMethods, methods]); return ( - - {children} - + {children} ); } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index da950396e..162baa884 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -131,6 +131,8 @@ export function useAgentFormState( setSelectedProvider(provider); userEditedPreferencesRef.current.model = true; setSelectedModel(""); + userEditedPreferencesRef.current.mode = true; + setSelectedMode(""); }, []); const setModeFromUser = useCallback((modeId: string) => { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 361ff4c81..5aa0b3413 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -212,9 +212,9 @@ export interface SessionState { messages: MessageEntry[]; currentAssistantMessage: string; - // Stream state - agentStreamState: Map; - agentStreamingBuffer: Map; + // Stream state (head/tail model) + agentStreamTail: Map; + agentStreamHead: Map; // Initializing agents initializingAgents: Map; @@ -270,17 +270,10 @@ interface SessionStoreActions { setMessages: (serverId: string, messages: MessageEntry[] | ((prev: MessageEntry[]) => MessageEntry[])) => void; setCurrentAssistantMessage: (serverId: string, message: string | ((prev: string) => string)) => void; - // Stream state - setAgentStreamState: (serverId: string, state: Map | ((prev: Map) => Map)) => void; - setAgentStreamingBuffer: ( - serverId: string, - state: - | Map - | (( - prev: Map - ) => Map) - ) => void; - clearAgentStreamingBuffer: (serverId: string, agentId: string) => void; + // Stream state (head/tail model) + setAgentStreamTail: (serverId: string, state: Map | ((prev: Map) => Map)) => void; + setAgentStreamHead: (serverId: string, state: Map | ((prev: Map) => Map)) => void; + clearAgentStreamHead: (serverId: string, agentId: string) => void; // Initializing agents setInitializingAgents: (serverId: string, state: Map | ((prev: Map) => Map)) => void; @@ -362,8 +355,8 @@ function createInitialSessionState(serverId: string, ws: UseWebSocketReturn, aud focusedAgentId: null, messages: [], currentAssistantMessage: "", - agentStreamState: new Map(), - agentStreamingBuffer: new Map(), + agentStreamTail: new Map(), + agentStreamHead: new Map(), initializingAgents: new Map(), agents: new Map(), pendingPermissions: new Map(), @@ -524,66 +517,66 @@ export const useSessionStore = create()( }); }, - // Stream state - setAgentStreamState: (serverId, state) => { + // Stream state (head/tail model) + setAgentStreamTail: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } - const nextState = typeof state === "function" ? state(session.agentStreamState) : state; - if (session.agentStreamState === nextState) { + const nextState = typeof state === "function" ? state(session.agentStreamTail) : state; + if (session.agentStreamTail === nextState) { return prev; } - logSessionStoreUpdate("setAgentStreamState", serverId, { agentCount: nextState.size }); + logSessionStoreUpdate("setAgentStreamTail", serverId, { agentCount: nextState.size }); return { ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, agentStreamState: nextState }, + [serverId]: { ...session, agentStreamTail: nextState }, }, }; }); }, - setAgentStreamingBuffer: (serverId, state) => { + setAgentStreamHead: (serverId, state) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } - const nextState = typeof state === "function" ? state(session.agentStreamingBuffer) : state; - if (session.agentStreamingBuffer === nextState) { + const nextState = typeof state === "function" ? state(session.agentStreamHead) : state; + if (session.agentStreamHead === nextState) { return prev; } - logSessionStoreUpdate("setAgentStreamingBuffer", serverId, { agentCount: nextState.size }); + logSessionStoreUpdate("setAgentStreamHead", serverId, { agentCount: nextState.size }); return { ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, agentStreamingBuffer: nextState }, + [serverId]: { ...session, agentStreamHead: nextState }, }, }; }); }, - clearAgentStreamingBuffer: (serverId, agentId) => { + clearAgentStreamHead: (serverId, agentId) => { set((prev) => { const session = prev.sessions[serverId]; if (!session) { return prev; } - if (!session.agentStreamingBuffer.has(agentId)) { + if (!session.agentStreamHead.has(agentId)) { return prev; } - const nextBuffer = new Map(session.agentStreamingBuffer); - nextBuffer.delete(agentId); - logSessionStoreUpdate("clearAgentStreamingBuffer", serverId, { agentId }); + const nextHead = new Map(session.agentStreamHead); + nextHead.delete(agentId); + logSessionStoreUpdate("clearAgentStreamHead", serverId, { agentId }); return { ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, agentStreamingBuffer: nextBuffer }, + [serverId]: { ...session, agentStreamHead: nextHead }, }, }; }); diff --git a/packages/app/src/types/stream-buffer.test.ts b/packages/app/src/types/stream-buffer.test.ts index e79df4362..7071a0687 100644 --- a/packages/app/src/types/stream-buffer.test.ts +++ b/packages/app/src/types/stream-buffer.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@server/server/messages"; -import type { StreamItem } from "@/types/stream"; -import { applyStreamEventWithBuffer, type StreamingBufferEntry } from "@/types/stream"; +import type { StreamItem, ThoughtItem } from "@/types/stream"; +import { + applyStreamEvent, + applyStreamEventWithBuffer, + type StreamingBufferEntry, +} from "@/types/stream"; const baseTimestamp = new Date(0); @@ -40,6 +44,15 @@ const permissionEvent = (): AgentStreamEventPayload => ({ }, }); +const reasoningChunk = (text: string): AgentStreamEventPayload => ({ + type: "timeline", + provider: "claude", + item: { + type: "reasoning", + text, + }, +}); + describe("applyStreamEventWithBuffer", () => { it("buffers assistant chunks without changing stream", () => { const stream: StreamItem[] = []; @@ -154,3 +167,100 @@ describe("applyStreamEventWithBuffer", () => { expect(result.buffer).toBe(null); }); }); + +describe("applyStreamEvent (head/tail model)", () => { + it("buffers reasoning chunks in head", () => { + const result = applyStreamEvent({ + tail: [], + head: [], + event: reasoningChunk("Let me think..."), + timestamp: baseTimestamp, + }); + + expect(result.tail).toHaveLength(0); + expect(result.head).toHaveLength(1); + expect(result.head[0].kind).toBe("thought"); + expect((result.head[0] as ThoughtItem).text).toBe("Let me think..."); + expect((result.head[0] as ThoughtItem).status).toBe("loading"); + }); + + it("accumulates reasoning chunks in head", () => { + let result = applyStreamEvent({ + tail: [], + head: [], + event: reasoningChunk("Let me "), + timestamp: baseTimestamp, + }); + result = applyStreamEvent({ + tail: result.tail, + head: result.head, + event: reasoningChunk("think..."), + timestamp: baseTimestamp, + }); + + expect(result.tail).toHaveLength(0); + expect(result.head).toHaveLength(1); + expect((result.head[0] as ThoughtItem).text).toBe("Let me think..."); + }); + + it("flushes reasoning to tail when tool call arrives", () => { + let result = applyStreamEvent({ + tail: [], + head: [], + event: reasoningChunk("Thinking..."), + timestamp: baseTimestamp, + }); + result = applyStreamEvent({ + tail: result.tail, + head: result.head, + event: toolCallEvent(), + timestamp: baseTimestamp, + }); + + expect(result.head).toHaveLength(0); + expect(result.tail).toHaveLength(2); + expect(result.tail[0].kind).toBe("thought"); + expect((result.tail[0] as ThoughtItem).status).toBe("ready"); + expect(result.tail[1].kind).toBe("tool_call"); + }); + + it("flushes head on turn completion", () => { + let result = applyStreamEvent({ + tail: [], + head: [], + event: assistantChunk("Hello"), + timestamp: baseTimestamp, + }); + result = applyStreamEvent({ + tail: result.tail, + head: result.head, + event: completionEvent(), + timestamp: baseTimestamp, + }); + + expect(result.head).toHaveLength(0); + expect(result.tail).toHaveLength(1); + expect(result.tail[0].kind).toBe("assistant_message"); + }); + + it("flushes reasoning when assistant message starts", () => { + let result = applyStreamEvent({ + tail: [], + head: [], + event: reasoningChunk("Thinking..."), + timestamp: baseTimestamp, + }); + result = applyStreamEvent({ + tail: result.tail, + head: result.head, + event: assistantChunk("Here's my answer"), + timestamp: baseTimestamp, + }); + + expect(result.tail).toHaveLength(1); + expect(result.tail[0].kind).toBe("thought"); + expect((result.tail[0] as ThoughtItem).status).toBe("ready"); + expect(result.head).toHaveLength(1); + expect(result.head[0].kind).toBe("assistant_message"); + }); +}); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index be74c4e2d..86c2ec130 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -29,7 +29,11 @@ export function generateMessageId(): string { return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } -function createTimelineId(prefix: string, text: string, timestamp: Date): string { +function createTimelineId( + prefix: string, + text: string, + timestamp: Date +): string { return `${prefix}_${timestamp.getTime()}_${simpleHash(text)}`; } @@ -132,7 +136,9 @@ export type AgentToolCallItem = ToolCallItem & { payload: { source: "agent"; data: AgentToolCallData }; }; -export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem { +export function isAgentToolCallItem( + item: StreamItem +): item is AgentToolCallItem { return item.kind === "tool_call" && item.payload.source === "agent"; } @@ -210,7 +216,11 @@ function appendUserMessage( return [...state, nextItem]; } -function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { +function appendAssistantMessage( + state: StreamItem[], + text: string, + timestamp: Date +): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { return state; @@ -240,7 +250,11 @@ function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Da return [...state, item]; } -function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { +function appendThought( + state: StreamItem[], + text: string, + timestamp: Date +): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { return state; @@ -298,9 +312,7 @@ function mergeToolCallRaw(existingRaw: unknown, nextRaw: unknown): unknown { return [existingRaw, nextRaw]; } -function computeParsedToolPayload( - result: unknown -): { +function computeParsedToolPayload(result: unknown): { parsedEdits?: EditEntry[]; parsedReads?: ReadEntry[]; parsedCommand?: CommandDetails | null; @@ -335,14 +347,16 @@ function findExistingAgentToolCallIndex( (entry) => entry.kind === "tool_call" && entry.payload.source === "agent" && - normalizeComparableString(entry.payload.data.callId) === normalizedCallId + normalizeComparableString(entry.payload.data.callId) === + normalizedCallId ); if (existingIndex >= 0) { return existingIndex; } } - const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> = []; + const fallbackCandidates: Array<{ index: number; item: AgentToolCallItem }> = + []; const metadataMatches: Array<{ index: number; item: AgentToolCallItem }> = []; for (let i = 0; i < state.length; i += 1) { const entry = state[i]; @@ -351,8 +365,7 @@ function findExistingAgentToolCallIndex( } const payload = entry.payload.data; const providerMatches = - payload.provider === data.provider && - payload.name === data.name; + payload.provider === data.provider && payload.name === data.name; if (providerMatches) { metadataMatches.push({ index: i, item: entry as AgentToolCallItem }); } @@ -397,7 +410,11 @@ function appendAgentToolCall( callId: callId ?? data.callId, }; - const existingIndex = findExistingAgentToolCallIndex(state, callId ?? null, payloadData); + const existingIndex = findExistingAgentToolCallIndex( + state, + callId ?? null, + payloadData + ); if (existingIndex >= 0) { const next = [...state]; @@ -429,7 +446,8 @@ function appendAgentToolCall( callId: payloadData.callId ?? existing.payload.data.callId, parsedEdits: parsed.parsedEdits ?? existing.payload.data.parsedEdits, parsedReads: parsed.parsedReads ?? existing.payload.data.parsedReads, - parsedCommand: parsed.parsedCommand ?? existing.payload.data.parsedCommand, + parsedCommand: + parsed.parsedCommand ?? existing.payload.data.parsedCommand, }, }, }; @@ -469,7 +487,8 @@ function isPermissionToolCall(raw: unknown): boolean { return candidate.name === "permission_request"; } -const FAILED_STATUS_PATTERN = /fail|error|deny|reject|cancel|abort|exception|refus/; +const FAILED_STATUS_PATTERN = + /fail|error|deny|reject|cancel|abort|exception|refus/; const COMPLETED_STATUS_PATTERN = /complete|success|granted|applied|done|resolved|finish|succeed|ok/; @@ -552,8 +571,8 @@ function inferStatusFromRaw(raw: unknown): "completed" | "failed" | null { typeof record.exitCode === "number" ? record.exitCode : typeof record.exit_code === "number" - ? record.exit_code - : null; + ? record.exit_code + : null; if (exitCode !== null) { return exitCode === 0 ? "completed" : "failed"; } @@ -657,7 +676,10 @@ function extractToolCallId(raw: unknown, depth = 0): string | null { return null; } -function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] { +function appendActivityLog( + state: StreamItem[], + entry: ActivityLogItem +): StreamItem[] { const index = state.findIndex((existing) => existing.id === entry.id); if (index >= 0) { const next = [...state]; @@ -679,7 +701,11 @@ function appendTodoList( })); const lastItem = state[state.length - 1]; - if (lastItem && lastItem.kind === "todo_list" && lastItem.provider === provider) { + if ( + lastItem && + lastItem.kind === "todo_list" && + lastItem.provider === provider + ) { const next = [...state]; const updated: TodoListItem = { ...lastItem, @@ -722,7 +748,12 @@ export function reduceStreamUpdate( let nextState = state; switch (item.type) { case "user_message": - nextState = appendUserMessage(state, item.text, timestamp, item.messageId); + nextState = appendUserMessage( + state, + item.text, + timestamp, + item.messageId + ); break; case "assistant_message": nextState = appendAssistantMessage(state, item.text, timestamp); @@ -790,18 +821,30 @@ export function reduceStreamUpdate( export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date }> ): StreamItem[] { - const hydrated = events.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp); - }, []); + const hydrated = events.reduce( + (state, { event, timestamp }) => { + return reduceStreamUpdate(state, event, timestamp); + }, + [] + ); return finalizeActiveThoughts(hydrated); } -export type StreamingBufferEntry = { - id: string; - text: string; - timestamp: Date; -}; +/** + * Streamable item kinds - items that can be incrementally streamed + * and should be buffered in the head before committing to tail. + */ +type StreamableKind = "assistant_message" | "thought"; + +const STREAMABLE_KINDS = new Set([ + "assistant_message", + "thought", +]); + +function isStreamableKind(kind: StreamItem["kind"]): kind is StreamableKind { + return STREAMABLE_KINDS.has(kind); +} const STREAM_COMPLETION_EVENTS = new Set([ "turn_completed", @@ -809,24 +852,190 @@ const STREAM_COMPLETION_EVENTS = new Set([ "turn_canceled", ]); -function appendCompletedAssistantMessage( - state: StreamItem[], - buffer: StreamingBufferEntry +/** + * Determine what kind of StreamItem an event would produce + */ +function getEventItemKind( + event: AgentStreamEventPayload +): StreamItem["kind"] | null { + if (event.type !== "timeline") { + return null; + } + switch (event.item.type) { + case "user_message": + return "user_message"; + case "assistant_message": + return "assistant_message"; + case "reasoning": + return "thought"; + case "tool_call": + return "tool_call"; + case "todo": + return "todo_list"; + case "error": + return "activity_log"; + default: + return null; + } +} + +/** + * Finalize head items before flushing to tail. + * Marks thoughts as "ready" since they're no longer being streamed. + */ +function finalizeHeadItems(head: StreamItem[]): StreamItem[] { + return head.map((item) => { + if (item.kind === "thought" && item.status !== "ready") { + return { ...item, status: "ready" as ThoughtStatus }; + } + return item; + }); +} + +/** + * Flush head items to tail, avoiding duplicates. + */ +function flushHeadToTail( + tail: StreamItem[], + head: StreamItem[] ): StreamItem[] { - if (state.some((item) => item.id === buffer.id)) { - return state; + if (head.length === 0) { + return tail; } - const entry: AssistantMessageItem = { - kind: "assistant_message", - id: buffer.id, - text: buffer.text, - timestamp: buffer.timestamp, + const finalized = finalizeHeadItems(head); + const tailIds = new Set(tail.map((item) => item.id)); + const newItems = finalized.filter((item) => !tailIds.has(item.id)); + + if (newItems.length === 0) { + return tail; + } + + return [...tail, ...newItems]; +} + +/** + * Determine if the head should be flushed based on incoming event kind. + * Flush when a different kind arrives or when the incoming kind is not streamable. + */ +function shouldFlushHead( + head: StreamItem[], + incomingKind: StreamItem["kind"] | null +): boolean { + if (head.length === 0) { + return false; + } + + // Non-timeline events don't trigger flush (except completion events handled separately) + if (incomingKind === null) { + return false; + } + + const lastHeadItem = head[head.length - 1]; + + // If incoming is not streamable, flush current head + if (!isStreamableKind(incomingKind)) { + return true; + } + + // If incoming kind is different from current head kind, flush + if (lastHeadItem.kind !== incomingKind) { + return true; + } + + return false; +} + +export interface ApplyStreamEventResult { + tail: StreamItem[]; + head: StreamItem[]; + changedTail: boolean; + changedHead: boolean; +} + +/** + * Apply a stream event using head/tail model. + * + * - Tail: committed history (rarely changes during streaming) + * - Head: active streaming items (frequently updated) + * + * Both use the same reduceStreamUpdate function. The difference is: + * - Streamable items (assistant_message, thought) go to head + * - Non-streamable items flush head to tail first, then go to tail + * - Turn completion events flush head to tail + */ +export function applyStreamEvent(params: { + tail: StreamItem[]; + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; +}): ApplyStreamEventResult { + const { tail, head, event, timestamp } = params; + let nextTail = tail; + let nextHead = head; + let changedTail = false; + let changedHead = false; + + const flushHead = () => { + if (nextHead.length === 0) { + return; + } + const flushed = flushHeadToTail(nextTail, nextHead); + if (flushed !== nextTail) { + nextTail = flushed; + changedTail = true; + } + nextHead = []; + changedHead = true; }; - return [...state, entry]; + // Handle turn completion events - flush everything + if (STREAM_COMPLETION_EVENTS.has(event.type)) { + flushHead(); + // Also finalize any remaining thoughts in tail + const finalized = finalizeActiveThoughts(nextTail); + if (finalized !== nextTail) { + nextTail = finalized; + changedTail = true; + } + return { tail: nextTail, head: nextHead, changedTail, changedHead }; + } + + const incomingKind = getEventItemKind(event); + + // Check if we need to flush head before processing this event + if (shouldFlushHead(nextHead, incomingKind)) { + flushHead(); + } + + // For streamable kinds, apply to head + if (incomingKind !== null && isStreamableKind(incomingKind)) { + const reduced = reduceStreamUpdate(nextHead, event, timestamp); + if (reduced !== nextHead) { + nextHead = reduced; + changedHead = true; + } + return { tail: nextTail, head: nextHead, changedTail, changedHead }; + } + + // For non-streamable kinds or non-timeline events, apply to tail + const reduced = reduceStreamUpdate(nextTail, event, timestamp); + if (reduced !== nextTail) { + nextTail = reduced; + changedTail = true; + } + + return { tail: nextTail, head: nextHead, changedTail, changedHead }; } +// Legacy export for backwards compatibility during migration +// TODO: Remove after all consumers are updated +export type StreamingBufferEntry = { + id: string; + text: string; + timestamp: Date; +}; + export function applyStreamEventWithBuffer(params: { state: StreamItem[]; buffer: StreamingBufferEntry | null; @@ -838,73 +1047,36 @@ export function applyStreamEventWithBuffer(params: { changedStream: boolean; changedBuffer: boolean; } { - const { state, buffer, event, timestamp } = params; - let nextStream = state; - let nextBuffer = buffer; - let changedStream = false; - let changedBuffer = false; + // Convert legacy buffer to head + const head: StreamItem[] = params.buffer + ? [ + { + kind: "assistant_message" as const, + id: params.buffer.id, + text: params.buffer.text, + timestamp: params.buffer.timestamp, + }, + ] + : []; - const commitBuffer = () => { - if (!nextBuffer) { - return; - } - const next = appendCompletedAssistantMessage(nextStream, nextBuffer); - if (next !== nextStream) { - nextStream = next; - changedStream = true; - } - nextBuffer = null; - changedBuffer = true; + const result = applyStreamEvent({ + tail: params.state, + head, + event: params.event, + timestamp: params.timestamp, + }); + + // Convert head back to legacy buffer format + const lastHead = result.head[result.head.length - 1]; + const newBuffer: StreamingBufferEntry | null = + lastHead && lastHead.kind === "assistant_message" + ? { id: lastHead.id, text: lastHead.text, timestamp: lastHead.timestamp } + : null; + + return { + stream: result.tail, + buffer: newBuffer, + changedStream: result.changedTail, + changedBuffer: result.changedHead, }; - - if (event.type === "timeline") { - if (event.item.type === "assistant_message") { - const { chunk, hasContent } = normalizeChunk(event.item.text); - if (!chunk) { - return { stream: nextStream, buffer: nextBuffer, changedStream, changedBuffer }; - } - if (!hasContent && !nextBuffer) { - return { stream: nextStream, buffer: nextBuffer, changedStream, changedBuffer }; - } - if (nextBuffer) { - const updatedText = `${nextBuffer.text}${chunk}`; - if (updatedText !== nextBuffer.text) { - nextBuffer = { ...nextBuffer, text: updatedText }; - changedBuffer = true; - } - } else { - const idSeed = chunk.trim() || chunk; - nextBuffer = { - id: createUniqueTimelineId(nextStream, "assistant", idSeed, timestamp), - text: chunk, - timestamp, - }; - changedBuffer = true; - } - return { stream: nextStream, buffer: nextBuffer, changedStream, changedBuffer }; - } - - if (nextBuffer) { - commitBuffer(); - } - - const reduced = reduceStreamUpdate(nextStream, event, timestamp); - if (reduced !== nextStream) { - nextStream = reduced; - changedStream = true; - } - return { stream: nextStream, buffer: nextBuffer, changedStream, changedBuffer }; - } - - if (STREAM_COMPLETION_EVENTS.has(event.type)) { - commitBuffer(); - return { stream: nextStream, buffer: nextBuffer, changedStream, changedBuffer }; - } - - const reduced = reduceStreamUpdate(nextStream, event, timestamp); - if (reduced !== nextStream) { - nextStream = reduced; - changedStream = true; - } - return { stream: nextStream, buffer: nextBuffer, changedStream, changedBuffer }; } diff --git a/packages/server/package.json b/packages/server/package.json index ae315beec..482a9779f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,6 +31,7 @@ "@lezer/markdown": "^1.6.2", "@lezer/python": "^1.1.18", "@modelcontextprotocol/sdk": "^1.20.1", + "@opencode-ai/sdk": "^1.1.12", "@openrouter/ai-sdk-provider": "^1.2.0", "ai": "^5.0.76", "dotenv": "^17.2.3", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 5341ea72a..52a8c8d00 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,6 +1,6 @@ import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk"; -export type AgentProvider = "codex" | "claude"; +export type AgentProvider = string; export type AgentMetadata = { [key: string]: unknown }; @@ -215,10 +215,15 @@ export interface AgentSession { setManagedAgentId?(agentId: string): void; } +export interface ListModelsOptions { + cwd?: string; +} + export interface AgentClient { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; createSession(config: AgentSessionConfig): Promise; resumeSession(handle: AgentPersistenceHandle, overrides?: Partial): Promise; + listModels(options?: ListModelsOptions): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 35aab80ae..62a21a0e7 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -21,7 +21,7 @@ import { } from "../messages.js"; import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; -import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js"; +import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js"; import { AgentRegistry } from "./agent-registry.js"; import { createWorktree } from "../../utils/worktree.js"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; diff --git a/packages/server/src/server/agent/model-catalog.e2e.test.ts b/packages/server/src/server/agent/model-catalog.e2e.test.ts index d0bbe7f92..16825644b 100644 --- a/packages/server/src/server/agent/model-catalog.e2e.test.ts +++ b/packages/server/src/server/agent/model-catalog.e2e.test.ts @@ -1,19 +1,32 @@ -import { describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { - fetchClaudeModelCatalog, - fetchCodexModelCatalog, -} from "./model-catalog.js"; + createDaemonTestContext, + type DaemonTestContext, +} from "../test-utils/index.js"; + +describe("provider model catalogs (e2e)", () => { + let ctx: DaemonTestContext; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + }); + + afterEach(async () => { + await ctx.cleanup(); + }, 60_000); -describe("provider model catalogs", () => { test( "Claude catalog exposes Sonnet and Haiku variants", async () => { - const models = await fetchClaudeModelCatalog(); - expect(models.length).toBeGreaterThan(0); + const result = await ctx.client.listProviderModels("claude"); - const descriptions = models - .map((model) => `${model.label} ${model.description ?? ""}`.toLowerCase()); + expect(result.error).toBeNull(); + expect(result.models.length).toBeGreaterThan(0); + + const descriptions = result.models.map( + (model) => `${model.label} ${model.description ?? ""}`.toLowerCase() + ); expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true); expect(descriptions.some((text) => text.includes("haiku"))).toBe(true); }, @@ -23,10 +36,35 @@ describe("provider model catalogs", () => { test( "Codex catalog exposes gpt-5.1-codex", async () => { - const models = await fetchCodexModelCatalog(); - const ids = models.map((model) => model.id); + const result = await ctx.client.listProviderModels("codex"); + + expect(result.error).toBeNull(); + const ids = result.models.map((model) => model.id); expect(ids).toContain("gpt-5.1-codex"); }, 180_000 ); + + test( + "OpenCode catalog returns models from multiple providers", + async () => { + const result = await ctx.client.listProviderModels("opencode"); + + expect(result.error).toBeNull(); + expect(result.models.length).toBeGreaterThan(0); + + for (const model of result.models) { + expect(model.provider).toBe("opencode"); + expect(model.id).toContain("/"); + expect(model.label).toBeTruthy(); + expect(model.metadata).toBeDefined(); + expect(model.metadata?.providerId).toBeTruthy(); + expect(model.metadata?.modelId).toBeTruthy(); + } + + const providerIds = new Set(result.models.map((m) => m.metadata?.providerId)); + expect(providerIds.size).toBeGreaterThan(0); + }, + 180_000 + ); }); diff --git a/packages/server/src/server/agent/model-catalog.ts b/packages/server/src/server/agent/model-catalog.ts deleted file mode 100644 index 249ea7956..000000000 --- a/packages/server/src/server/agent/model-catalog.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { - spawn, - type ChildProcess, - type ChildProcessWithoutNullStreams, -} from "node:child_process"; -import path from "node:path"; -import readline from "node:readline"; -import { fileURLToPath, URL } from "node:url"; - -import { - query, - type ModelInfo, - type Options, - type SDKUserMessage, -} from "@anthropic-ai/claude-agent-sdk"; - -import type { - AgentModelDefinition, - AgentProvider, -} from "./agent-sdk-types.js"; - -type ProviderModelCatalogOptions = { - cwd?: string; -}; - -type ClaudeModelInfo = ModelInfo; -type ClaudeOptions = Options; - -export async function fetchProviderModelCatalog( - provider: AgentProvider, - options?: ProviderModelCatalogOptions -): Promise { - if (provider === "claude") { - return fetchClaudeModelCatalog(options); - } - if (provider === "codex") { - return fetchCodexModelCatalog(); - } - throw new Error(`Unsupported provider '${provider}'`); -} - -export async function fetchClaudeModelCatalog( - options?: ProviderModelCatalogOptions -): Promise { - const prompt = emptySdkMessageStream(); - const claudeOptions: ClaudeOptions = { - cwd: options?.cwd ?? process.cwd(), - permissionMode: "plan", - includePartialMessages: false, - }; - - const claudeQuery = query({ prompt, options: claudeOptions }); - try { - const models: ClaudeModelInfo[] = await claudeQuery.supportedModels(); - return models.map((model) => ({ - provider: "claude", - id: model.value, - label: model.displayName, - description: model.description, - metadata: { - description: model.description, - }, - })); - } finally { - if (typeof claudeQuery.return === "function") { - try { - await claudeQuery.return(); - } catch { - // ignore shutdown errors - } - } - } -} - -export async function fetchCodexModelCatalog(): Promise { - const binaryPath = resolveCodexBinary(); - const child = spawn(binaryPath, ["app-server"], { - stdio: ["pipe", "pipe", "pipe"], - }); - assertChildHasPipes(child); - - const client = new CodexAppServerClient(child); - - try { - await client.request("initialize", { - clientInfo: { - name: "voice-dev", - title: "Voice Dev", - version: "0.0.0", - }, - }); - - const response = await client.request("model/list", {}); - if (!isCodexModelListResponse(response)) { - throw new Error("Unexpected Codex model list response"); - } - return response.data.map((model) => ({ - provider: "codex", - id: model.id, - label: model.displayName, - description: model.description, - isDefault: model.isDefault, - metadata: { - model: model.model, - defaultReasoningEffort: model.defaultReasoningEffort, - supportedReasoningEfforts: model.supportedReasoningEfforts, - }, - })); - } finally { - await client.dispose(); - } -} - -function emptySdkMessageStream(): AsyncIterable { - return (async function* empty() {})(); -} - -function resolveCodexBinary(): string { - const repoRoot = path.resolve( - fileURLToPath(new URL("../../../../..", import.meta.url)) - ); - const packageRoot = path.join(repoRoot, "node_modules", "@openai", "codex-sdk"); - const vendorDir = path.join(packageRoot, "vendor"); - - const { platform, arch } = process; - const triples: { [key: string]: string } = { - "darwin:x64": "x86_64-apple-darwin", - "darwin:arm64": "aarch64-apple-darwin", - "linux:x64": "x86_64-unknown-linux-musl", - "linux:arm64": "aarch64-unknown-linux-musl", - "win32:x64": "x86_64-pc-windows-msvc", - "win32:arm64": "aarch64-pc-windows-msvc", - }; - const triple = triples[`${platform}:${arch}`]; - if (!triple) { - throw new Error(`Unsupported Codex platform: ${platform} (${arch})`); - } - const binary = platform === "win32" ? "codex.exe" : "codex"; - return path.join(vendorDir, triple, "codex", binary); -} - -type CodexModelListResponse = { - data: CodexModelInfo[]; - nextCursor: string | null; -}; - -type CodexModelInfo = { - id: string; - model: string; - displayName: string; - description: string; - supportedReasoningEfforts: Array<{ reasoningEffort: string; description: string }>; - defaultReasoningEffort: string; - isDefault: boolean; -}; - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType; -}; - -function assertChildHasPipes( - child: ChildProcess -): asserts child is ChildProcessWithoutNullStreams { - if (!child.stdin || !child.stdout || !child.stderr) { - throw new Error("Codex app-server must be started with stdio pipes"); - } -} - -function isObject(value: unknown): value is { [key: string]: unknown } { - return typeof value === "object" && value !== null; -} - -function isCodexModelInfo(value: unknown): value is CodexModelInfo { - if (!isObject(value)) { - return false; - } - if (typeof value.id !== "string") { - return false; - } - if (typeof value.model !== "string") { - return false; - } - if (typeof value.displayName !== "string") { - return false; - } - if (typeof value.description !== "string") { - return false; - } - if (typeof value.defaultReasoningEffort !== "string") { - return false; - } - if (typeof value.isDefault !== "boolean") { - return false; - } - if (!Array.isArray(value.supportedReasoningEfforts)) { - return false; - } - for (const entry of value.supportedReasoningEfforts) { - if (!isObject(entry)) { - return false; - } - if (typeof entry.reasoningEffort !== "string") { - return false; - } - if (typeof entry.description !== "string") { - return false; - } - } - return true; -} - -function isCodexModelListResponse( - value: unknown -): value is CodexModelListResponse { - if (!isObject(value)) { - return false; - } - if (!Array.isArray(value.data)) { - return false; - } - if (value.nextCursor !== null && typeof value.nextCursor !== "string") { - return false; - } - return value.data.every((entry) => isCodexModelInfo(entry)); -} - -class CodexAppServerClient { - private readonly rl: readline.Interface; - private readonly pending = new Map(); - private nextId = 1; - private stderrBuffer = ""; - private disposed = false; - - constructor(private readonly child: ChildProcessWithoutNullStreams) { - this.rl = readline.createInterface({ input: child.stdout }); - this.rl.on("line", (line) => this.handleLine(line)); - - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk) => { - this.stderrBuffer += chunk; - }); - - child.on("exit", (code, signal) => { - if (this.disposed) { - return; - } - const message = code === 0 - ? "Codex app-server exited" - : `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`; - const error = new Error(this.stderrBuffer || message); - this.rejectAll(error); - }); - } - - async request( - method: string, - params: { [key: string]: unknown } - ): Promise { - if (this.disposed) { - throw new Error("Codex app-server client is closed"); - } - const id = this.nextId++; - const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; - this.child.stdin.write(payload); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (!this.pending.has(id)) { - return; - } - this.pending.delete(id); - reject(new Error(`Timeout waiting for '${method}' response`)); - }, 60_000); - this.pending.set(id, { resolve, reject, timer }); - }); - } - - async dispose(): Promise { - if (this.disposed) { - return; - } - this.disposed = true; - this.rl.close(); - if (this.child.stdin && !this.child.killed) { - this.child.stdin.end(); - setTimeout(() => { - if (this.child.exitCode === null && !this.child.killed) { - this.child.kill("SIGINT"); - } - }, 100); - } - } - - private handleLine(line: string) { - const trimmed = line.trim(); - if (!trimmed) { - return; - } - let message: any; - try { - message = JSON.parse(trimmed); - } catch { - this.stderrBuffer += `\n[stdout] ${trimmed}`; - return; - } - - if (message.id && this.pending.has(message.id)) { - const pending = this.pending.get(message.id)!; - this.pending.delete(message.id); - clearTimeout(pending.timer); - if (message.error) { - const error = new Error(message.error.message ?? "Codex RPC error"); - pending.reject(error); - } else { - pending.resolve(message.result); - } - return; - } - // ignore notifications - } - - private rejectAll(error: Error) { - for (const [, pending] of this.pending) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.pending.clear(); - } -} diff --git a/packages/server/src/server/agent/model-resolver.test.ts b/packages/server/src/server/agent/model-resolver.test.ts index 374d0e15a..aa2f27c46 100644 --- a/packages/server/src/server/agent/model-resolver.test.ts +++ b/packages/server/src/server/agent/model-resolver.test.ts @@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveAgentModel } from "./model-resolver.js"; -vi.mock("./model-catalog.js", () => ({ - fetchProviderModelCatalog: vi.fn(), +vi.mock("./provider-registry.js", () => ({ + fetchProviderModels: vi.fn(), })); -import { fetchProviderModelCatalog } from "./model-catalog.js"; +import { fetchProviderModels } from "./provider-registry.js"; -const mockedFetch = vi.mocked(fetchProviderModelCatalog); +const mockedFetch = vi.mocked(fetchProviderModels); describe("resolveAgentModel", () => { beforeEach(() => { diff --git a/packages/server/src/server/agent/model-resolver.ts b/packages/server/src/server/agent/model-resolver.ts index 8820cf6b5..03850f921 100644 --- a/packages/server/src/server/agent/model-resolver.ts +++ b/packages/server/src/server/agent/model-resolver.ts @@ -1,4 +1,4 @@ -import { fetchProviderModelCatalog } from "./model-catalog.js"; +import { fetchProviderModels } from "./provider-registry.js"; import type { AgentProvider } from "./agent-sdk-types.js"; import { expandTilde } from "../../utils/path.js"; @@ -17,7 +17,7 @@ export async function resolveAgentModel( } try { - const models = await fetchProviderModelCatalog(options.provider, { + const models = await fetchProviderModels(options.provider, { cwd: options.cwd ? expandTilde(options.cwd) : undefined, }); const preferred = models.find((model) => model.isDefault) ?? models[0]; diff --git a/packages/server/src/server/agent/opencode-reasoning.e2e.test.ts b/packages/server/src/server/agent/opencode-reasoning.e2e.test.ts new file mode 100644 index 000000000..3216a5b12 --- /dev/null +++ b/packages/server/src/server/agent/opencode-reasoning.e2e.test.ts @@ -0,0 +1,140 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { + createDaemonTestContext, + type DaemonTestContext, +} from "../test-utils/index.js"; +import type { AgentStreamEventPayload } from "../messages.js"; + +describe("OpenCode reasoning events (e2e)", () => { + let ctx: DaemonTestContext; + let agentCwd: string; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + agentCwd = await mkdtemp(path.join(os.tmpdir(), "opencode-reasoning-test-")); + }); + + afterEach(async () => { + await ctx.cleanup(); + await rm(agentCwd, { recursive: true, force: true }); + }, 60_000); + + test( + "gpt-5 nano emits reasoning events through daemon", + async () => { + const allEvents: Array<{ + event: AgentStreamEventPayload; + timestamp: string; + }> = []; + + // Subscribe to all events + ctx.client.on((event) => { + if (event.type === "agent_stream") { + allEvents.push({ + event: event.event, + timestamp: event.timestamp, + }); + } + }); + + // Create agent with gpt-5 nano model + const agent = await ctx.client.createAgent({ + provider: "opencode", + cwd: agentCwd, + model: "opencode/gpt-5-nano", + title: "reasoning-test", + }); + + expect(agent.id).toBeTruthy(); + + console.log("\n=== GPT-5 NANO E2E REASONING DEBUG ===\n"); + console.log(`Agent ID: ${agent.id}`); + console.log(`Agent status: ${agent.status}`); + + // Send a message that should trigger reasoning + await ctx.client.sendMessage(agent.id, "What is 2+2? Think step by step."); + + // Wait for agent to complete + const finalState = await ctx.client.waitForAgentIdle(agent.id, 120_000); + + console.log(`\nFinal status: ${finalState.status}`); + console.log(`Total events captured: ${allEvents.length}`); + + // Log all events + console.log("\n=== ALL EVENTS ===\n"); + for (let i = 0; i < allEvents.length; i++) { + const { event, timestamp } = allEvents[i]; + console.log(`[EVENT ${i + 1}] timestamp=${timestamp} type=${event.type}`); + console.log(JSON.stringify(event, null, 2)); + console.log("---"); + } + + // Group by type + const byType = new Map(); + for (const { event } of allEvents) { + byType.set(event.type, (byType.get(event.type) ?? 0) + 1); + } + console.log("\n=== EVENTS BY TYPE ==="); + for (const [type, count] of byType) { + console.log(` ${type}: ${count}`); + } + + // Check timeline events breakdown + const timelineEvents = allEvents.filter( + ({ event }) => event.type === "timeline" + ); + const itemTypes = new Map(); + for (const { event } of timelineEvents) { + if (event.type === "timeline") { + itemTypes.set(event.item.type, (itemTypes.get(event.item.type) ?? 0) + 1); + } + } + console.log("\n=== TIMELINE ITEM TYPES ==="); + for (const [type, count] of itemTypes) { + console.log(` ${type}: ${count}`); + } + + // Find reasoning events + const reasoningEvents = timelineEvents.filter( + ({ event }) => event.type === "timeline" && event.item.type === "reasoning" + ); + console.log(`\nReasoning events: ${reasoningEvents.length}`); + for (const { event } of reasoningEvents.slice(0, 5)) { + if (event.type === "timeline") { + console.log("Sample reasoning:", JSON.stringify(event.item, null, 2)); + } + } + + // Check for duplicate consecutive events + console.log("\n=== DUPLICATE CHECK ==="); + let duplicateCount = 0; + for (let i = 1; i < allEvents.length; i++) { + const prev = allEvents[i - 1]; + const curr = allEvents[i]; + if (JSON.stringify(prev.event) === JSON.stringify(curr.event)) { + duplicateCount++; + if (duplicateCount <= 5) { + console.log(`Duplicate at index ${i}:`, JSON.stringify(curr.event, null, 2)); + } + } + } + console.log(`Total duplicates: ${duplicateCount}`); + + console.log("\n=== END DEBUG ===\n"); + + // HARD ASSERT: Agent completed + expect(finalState.status).toBe("idle"); + + // HARD ASSERT: Got events + expect(allEvents.length).toBeGreaterThan(0); + + // Delete the agent + await ctx.client.deleteAgent(agent.id); + }, + 180_000 + ); +}); diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 8567bbdb3..bbe4dfe47 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -1,7 +1,8 @@ -import type { AgentMode, AgentProvider } from "./agent-sdk-types.js"; +import { z } from "zod"; +import type { AgentMode } from "./agent-sdk-types.js"; export interface AgentProviderDefinition { - id: AgentProvider; + id: string; label: string; description: string; defaultModeId: string | null; @@ -51,6 +52,14 @@ const CODEX_MODES: AgentMode[] = [ }, ]; +const OPENCODE_MODES: AgentMode[] = [ + { + id: "default", + label: "Default", + description: "Standard permission rules", + }, +]; + export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ { id: "claude", @@ -68,12 +77,28 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ defaultModeId: "auto", modes: CODEX_MODES, }, + { + id: "opencode", + label: "OpenCode", + description: + "Open-source coding assistant with multi-provider model support", + defaultModeId: "default", + modes: OPENCODE_MODES, + }, ]; -export function getAgentProviderDefinition(provider: AgentProvider): AgentProviderDefinition { +export function getAgentProviderDefinition(provider: string): AgentProviderDefinition { const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider); if (!definition) { throw new Error(`Unknown agent provider: ${provider}`); } return definition; } + +export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [string, ...string[]]; + +export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS); + +export function isValidAgentProvider(value: string): boolean { + return AGENT_PROVIDER_IDS.includes(value); +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts new file mode 100644 index 000000000..dfc72bd08 --- /dev/null +++ b/packages/server/src/server/agent/provider-registry.ts @@ -0,0 +1,80 @@ +import type { + AgentClient, + AgentModelDefinition, + AgentProvider, + ListModelsOptions, +} from "./agent-sdk-types.js"; + +import { ClaudeAgentClient } from "./providers/claude-agent.js"; +import { CodexMcpAgentClient } from "./providers/codex-mcp-agent.js"; +import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; + +import { + AGENT_PROVIDER_DEFINITIONS, + getAgentProviderDefinition, + type AgentProviderDefinition, +} from "./provider-manifest.js"; + +export type { + AgentProviderDefinition, +}; + +export { + AGENT_PROVIDER_DEFINITIONS, + getAgentProviderDefinition, +}; + +export interface ProviderDefinition extends AgentProviderDefinition { + createClient: () => AgentClient; + fetchModels: (options?: ListModelsOptions) => Promise; +} + +const claudeClient = new ClaudeAgentClient(); +const codexClient = new CodexMcpAgentClient(); +const opencodeClient = new OpenCodeAgentClient(); + +export const PROVIDER_REGISTRY: Record = { + claude: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!, + createClient: () => new ClaudeAgentClient(), + fetchModels: (options) => claudeClient.listModels(options), + }, + codex: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!, + createClient: () => new CodexMcpAgentClient(), + fetchModels: (options) => codexClient.listModels(options), + }, + opencode: { + ...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!, + createClient: () => new OpenCodeAgentClient(), + fetchModels: (options) => opencodeClient.listModels(options), + }, +}; + +export function getProviderDefinition(provider: AgentProvider): ProviderDefinition { + const definition = PROVIDER_REGISTRY[provider]; + if (!definition) { + throw new Error(`Unknown agent provider: ${provider}`); + } + return definition; +} + +export function getAllProviderDefinitions(): ProviderDefinition[] { + return Object.values(PROVIDER_REGISTRY); +} + +export function createAllClients(): Record { + const clients: Partial> = {}; + for (const [id, definition] of Object.entries(PROVIDER_REGISTRY)) { + clients[id as AgentProvider] = definition.createClient(); + } + return clients as Record; +} + +export async function fetchProviderModels( + provider: AgentProvider, + options?: { cwd?: string } +): Promise { + const definition = getProviderDefinition(provider); + return definition.fetchModels(options); +} diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 661e38315..1e4a59843 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -1307,3 +1307,40 @@ function rawContainsText(raw: unknown, text: string, depth = 0): boolean { // NOTE: Turn handoff integration tests are covered by the daemon E2E test: // "interrupting message should produce coherent text without garbling from race condition" // in daemon.e2e.test.ts which exercises the full flow through the WebSocket API. + +describe("ClaudeAgentClient.listModels", () => { + test( + "returns models with required fields", + async () => { + const client = new ClaudeAgentClient(); + const models = await client.listModels(); + + // HARD ASSERT: Returns an array + expect(Array.isArray(models)).toBe(true); + + // HARD ASSERT: At least one model is returned + expect(models.length).toBeGreaterThan(0); + + // HARD ASSERT: Each model has required fields with correct types + for (const model of models) { + expect(model.provider).toBe("claude"); + expect(typeof model.id).toBe("string"); + expect(model.id.length).toBeGreaterThan(0); + expect(typeof model.label).toBe("string"); + expect(model.label.length).toBeGreaterThan(0); + } + + // HARD ASSERT: Contains known Claude model IDs + const modelIds = models.map((m) => m.id); + const hasKnownModel = modelIds.some( + (id) => + id.includes("claude") || + id.includes("sonnet") || + id.includes("opus") || + id.includes("haiku") + ); + expect(hasKnownModel).toBe(true); + }, + 60_000 + ); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index cf2181521..b757344a3 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -8,6 +8,7 @@ import { type AgentDefinition, type CanUseTool, type McpServerConfig, + type ModelInfo, type Options, type PermissionMode, type PermissionResult, @@ -25,6 +26,7 @@ import type { AgentClient, AgentMetadata, AgentMode, + AgentModelDefinition, AgentPermissionRequest, AgentPermissionResponse, AgentPermissionUpdate, @@ -38,6 +40,7 @@ import type { AgentTimelineItem, AgentUsage, AgentRuntimeInfo, + ListModelsOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; @@ -324,6 +327,37 @@ export class ClaudeAgentClient implements AgentClient { }); } + async listModels(options?: ListModelsOptions): Promise { + const prompt = (async function* empty() {})(); + const claudeOptions: Options = { + cwd: options?.cwd ?? process.cwd(), + permissionMode: "plan", + includePartialMessages: false, + }; + + const claudeQuery = query({ prompt, options: claudeOptions }); + try { + const models: ModelInfo[] = await claudeQuery.supportedModels(); + return models.map((model) => ({ + provider: "claude" as const, + id: model.value, + label: model.displayName, + description: model.description, + metadata: { + description: model.description, + }, + })); + } finally { + if (typeof claudeQuery.return === "function") { + try { + await claudeQuery.return(); + } catch { + // ignore shutdown errors + } + } + } + } + async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise { const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const projectsRoot = path.join(configDir, "projects"); diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts index ab4de7060..033521890 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts @@ -1538,4 +1538,38 @@ describe("CodexMcpAgentClient (MCP integration)", () => { }, 180_000 ); + + test( + "listModels returns models with required fields", + async () => { + const { CodexMcpAgentClient } = await loadCodexMcpAgentClient(); + const client = new CodexMcpAgentClient(); + const models = await client.listModels(); + + // HARD ASSERT: Returns an array + expect(Array.isArray(models)).toBe(true); + + // HARD ASSERT: At least one model is returned + expect(models.length).toBeGreaterThan(0); + + // HARD ASSERT: Each model has required fields with correct types + for (const model of models) { + expect(model.provider).toBe("codex"); + expect(typeof model.id).toBe("string"); + expect(model.id.length).toBeGreaterThan(0); + expect(typeof model.label).toBe("string"); + expect(model.label.length).toBeGreaterThan(0); + } + + // HARD ASSERT: Exactly one model is marked as default + const defaultModels = models.filter((m) => m.isDefault === true); + expect(defaultModels.length).toBe(1); + + // HARD ASSERT: Default model has metadata with model info + const defaultModel = defaultModels[0]; + expect(defaultModel.metadata).toBeTruthy(); + expect(typeof defaultModel.metadata?.model).toBe("string"); + }, + 60_000 + ); }); diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index d858380a4..8f163a528 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -1,8 +1,14 @@ -import { execSync } from "node:child_process"; +import { + spawn, + execSync, + type ChildProcess, + type ChildProcessWithoutNullStreams, +} from "node:child_process"; import { randomUUID } from "node:crypto"; import { promises as fs, readdirSync, readFileSync, statSync, type Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; +import readline from "node:readline"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -13,6 +19,7 @@ import type { AgentCapabilityFlags, AgentClient, AgentMode, + AgentModelDefinition, AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, @@ -25,6 +32,7 @@ import type { AgentTimelineItem, AgentUsage, AgentRuntimeInfo, + ListModelsOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; @@ -4248,6 +4256,223 @@ export class CodexMcpAgentClient implements AgentClient { return descriptors; } + + async listModels(_options?: ListModelsOptions): Promise { + const binaryPath = resolveCodexBinary(); + const child = spawn(binaryPath, ["app-server"], { + stdio: ["pipe", "pipe", "pipe"], + }); + assertCodexChildHasPipes(child); + + const client = new CodexAppServerClient(child); + + try { + await client.request("initialize", { + clientInfo: { + name: "voice-dev", + title: "Voice Dev", + version: "0.0.0", + }, + }); + + const response = await client.request("model/list", {}); + if (!isCodexModelListResponse(response)) { + throw new Error("Unexpected Codex model list response"); + } + return response.data.map((model) => ({ + provider: "codex" as const, + id: model.id, + label: model.displayName, + description: model.description, + isDefault: model.isDefault, + metadata: { + model: model.model, + defaultReasoningEffort: model.defaultReasoningEffort, + supportedReasoningEfforts: model.supportedReasoningEfforts, + }, + })); + } finally { + await client.dispose(); + } + } +} + +// ============================================================================ +// Codex model listing helpers +// ============================================================================ + +type CodexModelListResponse = { + data: CodexModelInfo[]; + nextCursor: string | null; +}; + +type CodexModelInfo = { + id: string; + model: string; + displayName: string; + description: string; + supportedReasoningEfforts: Array<{ reasoningEffort: string; description: string }>; + defaultReasoningEffort: string; + isDefault: boolean; +}; + +type CodexPendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +}; + +function resolveCodexBinary(): string { + try { + const codexPath = execSync("which codex", { encoding: "utf8" }).trim(); + if (codexPath) { + return codexPath; + } + } catch { + // Fall through to error + } + throw new Error( + "Codex CLI not found. Please install codex globally: npm install -g @openai/codex" + ); +} + +function assertCodexChildHasPipes( + child: ChildProcess +): asserts child is ChildProcessWithoutNullStreams { + if (!child.stdin || !child.stdout || !child.stderr) { + throw new Error("Codex app-server must be started with stdio pipes"); + } +} + +function isCodexModelInfo(value: unknown): value is CodexModelInfo { + if (typeof value !== "object" || value === null) { + return false; + } + const obj = value as Record; + return ( + typeof obj.id === "string" && + typeof obj.model === "string" && + typeof obj.displayName === "string" && + typeof obj.description === "string" && + typeof obj.defaultReasoningEffort === "string" && + typeof obj.isDefault === "boolean" && + Array.isArray(obj.supportedReasoningEfforts) + ); +} + +function isCodexModelListResponse(value: unknown): value is CodexModelListResponse { + if (typeof value !== "object" || value === null) { + return false; + } + const obj = value as Record; + if (!Array.isArray(obj.data)) { + return false; + } + if (obj.nextCursor !== null && typeof obj.nextCursor !== "string") { + return false; + } + return obj.data.every((entry) => isCodexModelInfo(entry)); +} + +class CodexAppServerClient { + private readonly rl: readline.Interface; + private readonly pending = new Map(); + private nextId = 1; + private stderrBuffer = ""; + private disposed = false; + + constructor(private readonly child: ChildProcessWithoutNullStreams) { + this.rl = readline.createInterface({ input: child.stdout }); + this.rl.on("line", (line) => this.handleLine(line)); + + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + this.stderrBuffer += chunk; + }); + + child.on("exit", (code, signal) => { + if (this.disposed) { + return; + } + const message = code === 0 + ? "Codex app-server exited" + : `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`; + const error = new Error(this.stderrBuffer || message); + this.rejectAll(error); + }); + } + + async request( + method: string, + params: { [key: string]: unknown } + ): Promise { + if (this.disposed) { + throw new Error("Codex app-server client is closed"); + } + const id = this.nextId++; + const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; + this.child.stdin.write(payload); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (!this.pending.has(id)) { + return; + } + this.pending.delete(id); + reject(new Error(`Timeout waiting for '${method}' response`)); + }, 60_000); + this.pending.set(id, { resolve, reject, timer }); + }); + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + this.rl.close(); + if (this.child.stdin && !this.child.killed) { + this.child.stdin.end(); + setTimeout(() => { + if (this.child.exitCode === null && !this.child.killed) { + this.child.kill("SIGINT"); + } + }, 100); + } + } + + private handleLine(line: string): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + let message: { id?: number; result?: unknown; error?: { message?: string } }; + try { + message = JSON.parse(trimmed); + } catch { + this.stderrBuffer += `\n[stdout] ${trimmed}`; + return; + } + + if (message.id && this.pending.has(message.id)) { + const pending = this.pending.get(message.id)!; + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) { + const error = new Error(message.error.message ?? "Codex RPC error"); + pending.reject(error); + } else { + pending.resolve(message.result); + } + } + } + + private rejectAll(error: Error): void { + for (const [, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } } // ============================================================================ diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts new file mode 100644 index 000000000..4a2b932fd --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import type { + AgentSessionConfig, + AgentStreamEvent, + ToolCallTimelineItem, + AssistantMessageTimelineItem, + UserMessageTimelineItem, + AgentTimelineItem, +} from "../agent-sdk-types.js"; + +function tmpCwd(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-")); + try { + return realpathSync(dir); + } catch { + return dir; + } +} + +const TEST_MODEL = "opencode/glm-4.7-free"; + +interface TurnResult { + events: AgentStreamEvent[]; + assistantMessages: AssistantMessageTimelineItem[]; + toolCalls: ToolCallTimelineItem[]; + allTimelineItems: AgentTimelineItem[]; + turnCompleted: boolean; + turnFailed: boolean; + error?: string; +} + +async function collectTurnEvents( + iterator: AsyncGenerator +): Promise { + const result: TurnResult = { + events: [], + assistantMessages: [], + toolCalls: [], + allTimelineItems: [], + turnCompleted: false, + turnFailed: false, + }; + + for await (const event of iterator) { + result.events.push(event); + + if (event.type === "timeline") { + result.allTimelineItems.push(event.item); + if (event.item.type === "assistant_message") { + result.assistantMessages.push(event.item); + } else if (event.item.type === "tool_call") { + result.toolCalls.push(event.item); + } + } + + if (event.type === "turn_completed") { + result.turnCompleted = true; + break; + } + if (event.type === "turn_failed") { + result.turnFailed = true; + result.error = event.error; + break; + } + } + + return result; +} + +describe("OpenCodeAgentClient", () => { + const buildConfig = (cwd: string): AgentSessionConfig => ({ + provider: "opencode", + cwd, + model: TEST_MODEL, + }); + + test( + "creates a session with valid id and provider", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + // HARD ASSERT: Session has required fields + expect(typeof session.id).toBe("string"); + expect(session.id.length).toBeGreaterThan(0); + expect(session.provider).toBe("opencode"); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 60_000 + ); + + test( + "single turn completes with streaming deltas", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const iterator = session.stream("Say hello"); + const turn = await collectTurnEvents(iterator); + + // HARD ASSERT: Turn completed successfully + expect(turn.turnCompleted).toBe(true); + expect(turn.turnFailed).toBe(false); + + // HARD ASSERT: Got at least one assistant message + expect(turn.assistantMessages.length).toBeGreaterThan(0); + + // HARD ASSERT: Each delta is non-empty + for (const msg of turn.assistantMessages) { + expect(msg.text.length).toBeGreaterThan(0); + } + + // HARD ASSERT: Concatenated deltas form non-empty response + const fullResponse = turn.assistantMessages.map((m) => m.text).join(""); + expect(fullResponse.length).toBeGreaterThan(0); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 120_000 + ); + + test( + "user prompt text never appears in assistant_message", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const userMarker = "UNIQUE_USER_MARKER_XYZ789"; + const iterator = session.stream(`Reply with ACK only. ${userMarker}`); + const turn = await collectTurnEvents(iterator); + + // HARD ASSERT: Turn completed + expect(turn.turnCompleted).toBe(true); + + // HARD ASSERT: Got assistant response + expect(turn.assistantMessages.length).toBeGreaterThan(0); + + // HARD ASSERT: User marker never appears in ANY assistant message + for (const msg of turn.assistantMessages) { + expect(msg.text).not.toContain(userMarker); + } + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 120_000 + ); + + test( + "multi-turn preserves context", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const secretCode = "ZEBRA_42"; + + // Turn 1: Establish a fact + const turn1 = await collectTurnEvents( + session.stream(`Remember this code: ${secretCode}. Just say OK.`) + ); + + // HARD ASSERT: Turn 1 completed + expect(turn1.turnCompleted).toBe(true); + expect(turn1.assistantMessages.length).toBeGreaterThan(0); + + // Turn 2: Recall the fact + const turn2 = await collectTurnEvents( + session.stream("What was the code I told you? Reply with just the code.") + ); + + // HARD ASSERT: Turn 2 completed + expect(turn2.turnCompleted).toBe(true); + expect(turn2.assistantMessages.length).toBeGreaterThan(0); + + // HARD ASSERT: Response contains the secret code (context preserved) + const fullResponse = turn2.assistantMessages.map((m) => m.text).join(""); + expect(fullResponse).toContain(secretCode); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 180_000 + ); + + test( + "emits tool_call events for file operations", + async () => { + const cwd = tmpCwd(); + const testFile = path.join(cwd, "test-file.txt"); + writeFileSync(testFile, "original content\n"); + + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const iterator = session.stream(`Read the file at ${testFile}`); + const turn = await collectTurnEvents(iterator); + + // HARD ASSERT: Turn completed + expect(turn.turnCompleted).toBe(true); + + // HARD ASSERT: Got at least one tool call + expect(turn.toolCalls.length).toBeGreaterThan(0); + + // HARD ASSERT: Tool call has required fields + const firstTool = turn.toolCalls[0]; + expect(typeof firstTool.name).toBe("string"); + expect(firstTool.name.length).toBeGreaterThan(0); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 120_000 + ); + + test( + "can be interrupted during streaming", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const events: AgentStreamEvent[] = []; + const iterator = session.stream( + "Write a very long essay about computing history with at least 10 paragraphs." + ); + + // Collect a few events then interrupt + for await (const event of iterator) { + events.push(event); + if (events.length >= 5) { + await session.interrupt(); + break; + } + } + + // HARD ASSERT: Received events before interruption + expect(events.length).toBeGreaterThanOrEqual(5); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 60_000 + ); + + test( + "run() returns accumulated response text", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const marker = "OPENCODE_ACK_TOKEN"; + const result = await session.run(`Reply with exactly: ${marker}`); + + // HARD ASSERT: Result has finalText + expect(typeof result.finalText).toBe("string"); + + // HARD ASSERT: Response contains the marker + expect(result.finalText).toContain(marker); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 120_000 + ); + + test( + "handles permission requests", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + const events: AgentStreamEvent[] = []; + const iterator = session.stream("Run the command 'echo hello' in a shell."); + + for await (const event of iterator) { + events.push(event); + + if (event.type === "permission_requested") { + // HARD ASSERT: Permission request has required fields + expect(typeof event.request.id).toBe("string"); + expect(event.request.id.length).toBeGreaterThan(0); + + // Approve it + await session.respondToPermission(event.request.id, { + behavior: "allow", + }); + } + + if (event.type === "turn_completed" || event.type === "turn_failed") { + break; + } + } + + // HARD ASSERT: Turn completed (either with or without permission) + const hasCompletion = events.some( + (e) => e.type === "turn_completed" || e.type === "turn_failed" + ); + expect(hasCompletion).toBe(true); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 120_000 + ); + + test( + "listModels returns models with required fields", + async () => { + const client = new OpenCodeAgentClient(); + const models = await client.listModels(); + + // HARD ASSERT: Returns an array + expect(Array.isArray(models)).toBe(true); + + // HARD ASSERT: At least one model is returned (OpenCode has connected providers) + expect(models.length).toBeGreaterThan(0); + + // HARD ASSERT: Each model has required fields with correct types + for (const model of models) { + expect(model.provider).toBe("opencode"); + expect(typeof model.id).toBe("string"); + expect(model.id.length).toBeGreaterThan(0); + expect(typeof model.label).toBe("string"); + expect(model.label.length).toBeGreaterThan(0); + + // HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId) + expect(model.id).toContain("/"); + } + }, + 60_000 + ); + + test( + "streamHistory returns exact conversation history after multi-turn session", + async () => { + const cwd = tmpCwd(); + const client = new OpenCodeAgentClient(); + const session = await client.createSession(buildConfig(cwd)); + + // Turn 1: Ask agent to remember a secret + const secretCode = "HISTORY_TEST_42"; + const turn1 = await collectTurnEvents( + session.stream(`Remember this code: ${secretCode}. Reply with just "OK".`) + ); + + // HARD ASSERT: Turn 1 completed + expect(turn1.turnCompleted).toBe(true); + expect(turn1.assistantMessages.length).toBeGreaterThan(0); + + // Turn 2: Ask agent to read a file (triggers tool use) + const testFile = path.join(cwd, "history-test.txt"); + writeFileSync(testFile, "history test content\n"); + const turn2 = await collectTurnEvents( + session.stream(`Read the file at ${testFile} and tell me what it contains.`) + ); + + // HARD ASSERT: Turn 2 completed with tool call + expect(turn2.turnCompleted).toBe(true); + expect(turn2.toolCalls.length).toBeGreaterThan(0); + expect(turn2.assistantMessages.length).toBeGreaterThan(0); + + // Now load history from the session + const historyEvents: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + historyEvents.push(event); + } + + // HARD ASSERT: History contains events + expect(historyEvents.length).toBeGreaterThan(0); + + // Extract timeline items from history + const historyTimeline = historyEvents + .filter((e): e is Extract => e.type === "timeline") + .map((e) => e.item); + + // HARD ASSERT: History has user messages + const userMessages = historyTimeline.filter( + (item): item is UserMessageTimelineItem => item.type === "user_message" + ); + expect(userMessages.length).toBe(2); + + // HARD ASSERT: First user message contains our secret code prompt + expect(userMessages[0].text).toContain(secretCode); + + // HARD ASSERT: Second user message contains the file read request + expect(userMessages[1].text).toContain("history-test.txt"); + + // HARD ASSERT: History has assistant messages + const assistantMessages = historyTimeline.filter( + (item): item is AssistantMessageTimelineItem => item.type === "assistant_message" + ); + expect(assistantMessages.length).toBeGreaterThan(0); + + // HARD ASSERT: History has tool calls from turn 2 + const toolCalls = historyTimeline.filter( + (item): item is ToolCallTimelineItem => item.type === "tool_call" + ); + expect(toolCalls.length).toBeGreaterThan(0); + + // HARD ASSERT: Tool call has correct structure + const firstToolCall = toolCalls[0]; + expect(typeof firstToolCall.name).toBe("string"); + expect(firstToolCall.name.length).toBeGreaterThan(0); + expect(typeof firstToolCall.callId).toBe("string"); + expect(firstToolCall.callId!.length).toBeGreaterThan(0); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, + 180_000 + ); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts new file mode 100644 index 000000000..0954ccb93 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -0,0 +1,798 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import net from "node:net"; + +import type { + AgentCapabilityFlags, + AgentClient, + AgentMetadata, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentRuntimeInfo, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + AgentUsage, + ListModelsOptions, + ListPersistedAgentsOptions, + PersistedAgentDescriptor, +} from "../agent-sdk-types.js"; + +const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const DEFAULT_MODES: AgentMode[] = [ + { + id: "default", + label: "Default", + description: "Standard permission rules", + }, +]; + +type OpenCodeAgentConfig = AgentSessionConfig & { provider: "opencode" }; + +async function findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, () => { + const address = server.address(); + if (address && typeof address === "object") { + const port = address.port; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Failed to get port"))); + } + }); + server.on("error", reject); + }); +} + +export class OpenCodeServerManager { + private static instance: OpenCodeServerManager | null = null; + private server: ChildProcess | null = null; + private port: number | null = null; + private startPromise: Promise<{ port: number; url: string }> | null = null; + + static getInstance(): OpenCodeServerManager { + if (!OpenCodeServerManager.instance) { + OpenCodeServerManager.instance = new OpenCodeServerManager(); + } + return OpenCodeServerManager.instance; + } + + async ensureRunning(): Promise<{ port: number; url: string }> { + if (this.startPromise) { + return this.startPromise; + } + + if (this.server && this.port && !this.server.killed) { + return { port: this.port, url: `http://127.0.0.1:${this.port}` }; + } + + this.startPromise = this.startServer(); + try { + const result = await this.startPromise; + return result; + } finally { + this.startPromise = null; + } + } + + private async startServer(): Promise<{ port: number; url: string }> { + this.port = await findAvailablePort(); + const url = `http://127.0.0.1:${this.port}`; + + return new Promise((resolve, reject) => { + this.server = spawn("opencode", ["serve", "--port", String(this.port)], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + let started = false; + const timeout = setTimeout(() => { + if (!started) { + reject(new Error("OpenCode server startup timeout")); + } + }, 30_000); + + this.server.stdout?.on("data", (data: Buffer) => { + const output = data.toString(); + if (output.includes("listening on") && !started) { + started = true; + clearTimeout(timeout); + resolve({ port: this.port!, url }); + } + }); + + this.server.stderr?.on("data", (data: Buffer) => { + console.error("[OpenCode Server]", data.toString().trim()); + }); + + this.server.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + + this.server.on("exit", (code) => { + if (!started) { + clearTimeout(timeout); + reject(new Error(`OpenCode server exited with code ${code}`)); + } + this.server = null; + this.port = null; + }); + }); + } + + async shutdown(): Promise { + if (this.server && !this.server.killed) { + this.server.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + this.server?.kill("SIGKILL"); + resolve(); + }, 5000); + this.server?.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + this.server = null; + this.port = null; + } +} + +export class OpenCodeAgentClient implements AgentClient { + readonly provider: "opencode" = "opencode"; + readonly capabilities = OPENCODE_CAPABILITIES; + + private serverManager = OpenCodeServerManager.getInstance(); + + async createSession(config: AgentSessionConfig): Promise { + const openCodeConfig = this.assertConfig(config); + const { url } = await this.serverManager.ensureRunning(); + const client = createOpencodeClient({ + baseUrl: url, + directory: openCodeConfig.cwd, + }); + + const response = await client.session.create({ + directory: openCodeConfig.cwd, + }); + + if (response.error) { + throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`); + } + + const session = response.data; + if (!session) { + throw new Error("OpenCode session creation returned no data"); + } + + return new OpenCodeAgentSession(openCodeConfig, client, session.id); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial + ): Promise { + const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string); + if (!cwd) { + throw new Error("OpenCode resume requires the original working directory"); + } + + const config: AgentSessionConfig = { + provider: "opencode", + cwd, + ...overrides, + }; + const openCodeConfig = this.assertConfig(config); + const { url } = await this.serverManager.ensureRunning(); + const client = createOpencodeClient({ + baseUrl: url, + directory: openCodeConfig.cwd, + }); + + return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId); + } + + async listModels(options?: ListModelsOptions): Promise { + const { url } = await this.serverManager.ensureRunning(); + const client = createOpencodeClient({ + baseUrl: url, + directory: options?.cwd ?? process.cwd(), + }); + + const response = await client.provider.list({ + directory: options?.cwd ?? process.cwd(), + }); + + if (response.error) { + throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`); + } + + const providers = response.data; + if (!providers) { + return []; + } + + // Only include models from connected providers (ones that are actually available) + const connectedProviderIds = new Set(providers.connected); + + const models: AgentModelDefinition[] = []; + for (const provider of providers.all) { + // Skip providers that aren't connected/configured + if (!connectedProviderIds.has(provider.id)) { + continue; + } + + for (const [modelId, model] of Object.entries(provider.models)) { + models.push({ + provider: "opencode", + id: `${provider.id}/${modelId}`, + label: model.name, + description: `${provider.name} - ${model.family ?? ""}`.trim(), + metadata: { + providerId: provider.id, + providerName: provider.name, + modelId, + family: model.family, + releaseDate: model.release_date, + supportsAttachments: model.attachment, + supportsReasoning: model.reasoning, + supportsToolCall: model.tool_call, + cost: model.cost, + }, + }); + } + } + + return models; + } + + async listPersistedAgents(_options?: ListPersistedAgentsOptions): Promise { + // TODO: Implement by listing sessions from OpenCode + return []; + } + + private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig { + if (config.provider !== "opencode") { + throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); + } + return { ...config, provider: "opencode" }; + } +} + +class OpenCodeAgentSession implements AgentSession { + readonly provider: "opencode" = "opencode"; + readonly capabilities = OPENCODE_CAPABILITIES; + + private readonly config: OpenCodeAgentConfig; + private readonly client: OpencodeClient; + private readonly sessionId: string; + private currentMode: string = "default"; + private pendingPermissions = new Map(); + private abortController: AbortController | null = null; + private accumulatedUsage: AgentUsage = {}; + /** Tracks the role of each message by ID to distinguish user from assistant messages */ + private messageRoles = new Map(); + + constructor( + config: OpenCodeAgentConfig, + client: OpencodeClient, + sessionId: string + ) { + this.config = config; + this.client = client; + this.sessionId = sessionId; + } + + get id(): string | null { + return this.sessionId; + } + + async getRuntimeInfo(): Promise { + return { + provider: "opencode", + sessionId: this.sessionId, + model: this.config.model ?? null, + modeId: this.currentMode, + }; + } + + async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { + const events = this.stream(prompt); + const timeline: AgentTimelineItem[] = []; + let finalText = ""; + let usage: AgentUsage | undefined; + + for await (const event of events) { + if (event.type === "timeline") { + timeline.push(event.item); + if (event.item.type === "assistant_message") { + finalText = event.item.text; + } + } else if (event.type === "turn_completed") { + usage = event.usage; + } else if (event.type === "turn_failed") { + throw new Error(event.error); + } + } + + return { + sessionId: this.sessionId, + finalText, + usage, + timeline, + }; + } + + async *stream( + prompt: AgentPromptInput, + _options?: AgentRunOptions + ): AsyncGenerator { + this.abortController = new AbortController(); + + const parts = this.buildPromptParts(prompt); + const model = this.parseModel(this.config.model); + + // Send prompt asynchronously + const promptResponse = await this.client.session.promptAsync({ + sessionID: this.sessionId, + directory: this.config.cwd, + parts, + ...(model ? { model } : {}), + }); + + if (promptResponse.error) { + yield { + type: "turn_failed", + provider: "opencode", + error: JSON.stringify(promptResponse.error), + }; + return; + } + + // Subscribe to events + const eventsResult = await this.client.event.subscribe({ + directory: this.config.cwd, + }); + + try { + for await (const event of eventsResult.stream) { + if (this.abortController.signal.aborted) { + break; + } + + const translated = this.translateEvent(event); + for (const e of translated) { + yield e; + if (e.type === "turn_completed" || e.type === "turn_failed") { + return; + } + } + } + } catch (error) { + if (!this.abortController.signal.aborted) { + yield { + type: "turn_failed", + provider: "opencode", + error: error instanceof Error ? error.message : "Stream error", + }; + } + } + } + + async interrupt(): Promise { + this.abortController?.abort(); + await this.client.session.abort({ + sessionID: this.sessionId, + directory: this.config.cwd, + }); + } + + async *streamHistory(): AsyncGenerator { + const response = await this.client.session.messages({ + sessionID: this.sessionId, + directory: this.config.cwd, + }); + + if (response.error || !response.data) { + return; + } + + const messages = response.data; + + for (const message of messages) { + const { info, parts } = message; + const role = info.role as "user" | "assistant"; + + if (role === "user") { + // Extract user message text from parts + const textParts = parts.filter( + (p) => (p as { type?: string }).type === "text" + ); + const text = textParts + .map((p) => (p as { text?: string }).text ?? "") + .join(""); + + if (text) { + yield { + type: "timeline", + provider: "opencode", + item: { type: "user_message", text }, + }; + } + } else if (role === "assistant") { + // Process each part + for (const part of parts) { + const partType = (part as { type?: string }).type; + + if (partType === "text") { + const text = (part as { text?: string }).text; + if (text) { + yield { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text }, + }; + } + } else if (partType === "reasoning") { + const text = (part as { text?: string }).text; + if (text) { + yield { + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text }, + }; + } + } else if (partType === "tool") { + const toolPart = part as { + id?: string; + tool?: string; + callID?: string; + state?: { + status?: string; + input?: AgentMetadata; + output?: string; + error?: string; + }; + }; + const toolName = toolPart.tool; + const state = toolPart.state; + + if (toolName) { + yield { + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + name: toolName, + callId: toolPart.callID ?? toolPart.id, + status: this.mapToolState(state?.status), + input: state?.input, + output: state?.output, + error: state?.error, + }, + }; + } + } + } + } + } + } + + async getAvailableModes(): Promise { + return DEFAULT_MODES; + } + + async getCurrentMode(): Promise { + return this.currentMode; + } + + async setMode(modeId: string): Promise { + this.currentMode = modeId; + } + + getPendingPermissions(): AgentPermissionRequest[] { + return Array.from(this.pendingPermissions.values()); + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + const pending = this.pendingPermissions.get(requestId); + if (!pending) { + throw new Error(`No pending permission request with id '${requestId}'`); + } + + const reply = response.behavior === "allow" ? "once" : "reject"; + await this.client.permission.reply({ + requestID: requestId, + directory: this.config.cwd, + reply, + message: response.behavior === "deny" ? response.message : undefined, + }); + + this.pendingPermissions.delete(requestId); + } + + describePersistence(): AgentPersistenceHandle | null { + return { + provider: "opencode", + sessionId: this.sessionId, + nativeHandle: this.sessionId, + metadata: { + cwd: this.config.cwd, + }, + }; + } + + async close(): Promise { + this.abortController?.abort(); + } + + private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> { + if (typeof prompt === "string") { + return [{ type: "text", text: prompt }]; + } + return prompt + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => ({ type: "text", text: p.text })); + } + + private parseModel(model?: string): { providerID: string; modelID: string } | undefined { + if (!model) { + return undefined; + } + const parts = model.split("/"); + if (parts.length >= 2) { + return { providerID: parts[0], modelID: parts.slice(1).join("/") }; + } + return { providerID: "opencode", modelID: model }; + } + + private translateEvent(event: unknown): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + + if (!event || typeof event !== "object") { + return events; + } + + const e = event as { type?: string; properties?: AgentMetadata }; + const type = e.type; + const props = e.properties ?? {}; + + switch (type) { + case "session.created": + case "session.updated": { + const sessionId = props.id as string | undefined; + if (sessionId === this.sessionId) { + events.push({ + type: "thread_started", + sessionId: this.sessionId, + provider: "opencode", + }); + } + break; + } + + case "message.updated": { + // Track message roles by ID so we can distinguish user from assistant parts + const info = props.info as AgentMetadata | undefined; + if (!info) { + break; + } + const messageId = info.id as string | undefined; + const messageSessionId = info.sessionID as string | undefined; + const role = info.role as "user" | "assistant" | undefined; + + if (messageId && messageSessionId === this.sessionId && role) { + this.messageRoles.set(messageId, role); + } + break; + } + + case "message.part.updated": { + // Structure: { part: { id, sessionID, messageID, type, text?, ... }, delta?: string } + const part = props.part as AgentMetadata | undefined; + const delta = props.delta as string | undefined; + if (!part) { + break; + } + + const partSessionId = part.sessionID as string | undefined; + if (partSessionId !== this.sessionId) { + break; + } + + const messageId = part.messageID as string | undefined; + const messageRole = messageId ? this.messageRoles.get(messageId) : undefined; + const partType = part.type as string | undefined; + const partTime = part.time as { start?: number; end?: number } | undefined; + + if (partType === "text") { + // Skip user messages - agent-manager emits user_message via recordUserMessage + if (messageRole === "user") { + break; + } + // Skip if role unknown AND no delta (likely user message before role is known) + if (!messageRole && !delta) { + break; + } + // Emit delta for streaming, or full text only when complete (has time.end) + if (delta) { + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: delta }, + }); + } else if (partTime?.end) { + // Final complete text - only emit if we haven't been streaming deltas + // (In practice, if delta was present, we've already streamed it all) + const text = part.text as string | undefined; + if (text) { + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text }, + }); + } + } + } else if (partType === "reasoning") { + // Emit delta for streaming reasoning + if (delta) { + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text: delta }, + }); + } else if (partTime?.end) { + const text = part.text as string | undefined; + if (text) { + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text }, + }); + } + } + } else if (partType === "tool") { + // Tool parts: { tool: string, state: { status, input, output?, error? } } + const toolName = part.tool as string | undefined; + const state = part.state as AgentMetadata | undefined; + const status = state?.status as string | undefined; + const input = state?.input as AgentMetadata | undefined; + const output = state?.output as string | undefined; + const error = state?.error as string | undefined; + + if (toolName) { + events.push({ + type: "timeline", + provider: "opencode", + item: { + type: "tool_call", + name: toolName, + callId: part.callID as string | undefined, + status: this.mapToolState(status), + input, + output, + error, + }, + }); + } + } else if (partType === "step-finish") { + // Extract usage from step-finish parts + const tokens = part.tokens as { input?: number; output?: number; reasoning?: number } | undefined; + const cost = part.cost as number | undefined; + + if (tokens) { + this.accumulatedUsage.inputTokens = (this.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0); + this.accumulatedUsage.outputTokens = (this.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0); + } + if (cost !== undefined) { + this.accumulatedUsage.totalCostUsd = (this.accumulatedUsage.totalCostUsd ?? 0) + cost; + } + } + break; + } + + case "permission.asked": { + // props IS the PermissionRequest directly + const sessionId = props.sessionID as string | undefined; + if (sessionId !== this.sessionId) { + break; + } + + const requestId = props.id as string; + const permission = props.permission as string; + const metadata = props.metadata as AgentMetadata | undefined; + const patterns = props.patterns as string[] | undefined; + + const permRequest: AgentPermissionRequest = { + id: requestId, + provider: "opencode", + name: permission, + kind: "tool", + title: permission, + description: patterns?.join(", "), + input: metadata, + }; + + this.pendingPermissions.set(requestId, permRequest); + events.push({ + type: "permission_requested", + provider: "opencode", + request: permRequest, + }); + break; + } + + case "session.idle": { + const sessionId = props.sessionID as string | undefined; + if (sessionId === this.sessionId) { + const usage = this.extractAndResetUsage(); + events.push({ + type: "turn_completed", + provider: "opencode", + usage, + }); + } + break; + } + + case "session.error": { + const sessionId = props.sessionID as string | undefined; + if (sessionId === this.sessionId) { + const error = props.error as string | undefined; + events.push({ + type: "turn_failed", + provider: "opencode", + error: error ?? "Unknown error", + }); + } + break; + } + } + + return events; + } + + private mapToolState(state?: string): string { + switch (state) { + case "pending": + return "pending"; + case "running": + return "running"; + case "complete": + return "completed"; + case "error": + return "failed"; + default: + return "pending"; + } + } + + private extractAndResetUsage(): AgentUsage | undefined { + const usage = this.accumulatedUsage; + this.accumulatedUsage = {}; + + if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) { + return undefined; + } + + return usage; + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 5595dda5d..c28565c2a 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -14,11 +14,10 @@ import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js"; import { listConversations, deleteConversation } from "./persistence.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentRegistry } from "./agent/agent-registry.js"; -import { ClaudeAgentClient } from "./agent/providers/claude-agent.js"; -import { CodexMcpAgentClient } from "./agent/providers/codex-mcp-agent.js"; import { initializeTitleGenerator } from "../services/agent-title-generator.js"; import { attachAgentRegistryPersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; +import { createAllClients } from "./agent/provider-registry.js"; import type { AgentClient, AgentControlMcpConfig, @@ -189,8 +188,7 @@ export async function createPaseoDaemon( const agentRegistry = new AgentRegistry(config.agentRegistryPath); const agentManager = new AgentManager({ clients: { - claude: new ClaudeAgentClient(), - codex: new CodexMcpAgentClient(), + ...createAllClients(), ...config.agentClients, }, registry: agentRegistry, diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index a1f90affa..7d06e4c21 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -4,6 +4,7 @@ import { type ManagedAgent, } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; +import { AgentProviderSchema } from "./agent/provider-manifest.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -11,16 +12,12 @@ import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, - AgentProvider, AgentRuntimeInfo, AgentStreamEvent, AgentTimelineItem, AgentUsage, } from "./agent/agent-sdk-types.js"; -const AGENT_PROVIDERS: [AgentProvider, AgentProvider] = ["claude", "codex"]; -const AgentProviderSchema = z.enum(AGENT_PROVIDERS); - export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES); const AgentModeSchema: z.ZodType = z.object({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b55f2bd4c..6db1f9b3d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -36,7 +36,7 @@ import { } from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { fetchProviderModelCatalog } from "./agent/model-catalog.js"; +import { fetchProviderModels } from "./agent/provider-registry.js"; import { AgentManager } from "./agent/agent-manager.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import { toAgentPayload } from "./agent/agent-projections.js"; @@ -50,6 +50,7 @@ import type { AgentPersistenceHandle, } from "./agent/agent-sdk-types.js"; import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js"; +import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { listDirectoryEntries, readExplorerFile, @@ -81,7 +82,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { const ACTIVE_TITLE_GENERATIONS = new Set(); const pendingAgentInitializations = new Map>(); let restartRequested = false; -const KNOWN_AGENT_PROVIDERS: AgentProvider[] = ["claude", "codex"]; +const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; const RESTART_EXIT_DELAY_MS = 250; type ProcessingPhase = "idle" | "transcribing" | "llm"; @@ -153,18 +154,14 @@ function convertPCMToWavBuffer( return wavBuffer; } -function isKnownAgentProvider(value: string): value is AgentProvider { - return KNOWN_AGENT_PROVIDERS.includes(value as AgentProvider); -} - function coerceAgentProvider(value: string, agentId?: string): AgentProvider { - if (isKnownAgentProvider(value)) { + if (isValidAgentProvider(value)) { return value; } console.warn( - `[Session] Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to 'claude'` + `[Session] Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'` ); - return "claude"; + return DEFAULT_AGENT_PROVIDER; } function toAgentPersistenceHandle( @@ -174,7 +171,7 @@ function toAgentPersistenceHandle( return null; } const provider = handle.provider; - if (!isKnownAgentProvider(provider)) { + if (!isValidAgentProvider(provider)) { console.warn( `[Session] Ignoring persistence handle with unknown provider '${provider}'` ); @@ -1583,7 +1580,7 @@ export class Session { ): Promise { const fetchedAt = new Date().toISOString(); try { - const models = await fetchProviderModelCatalog(msg.provider, { + const models = await fetchProviderModels(msg.provider, { cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, }); this.emit({ diff --git a/packages/server/src/server/test-utils/daemon-client.ts b/packages/server/src/server/test-utils/daemon-client.ts index 6cc20a095..cb53c1edb 100644 --- a/packages/server/src/server/test-utils/daemon-client.ts +++ b/packages/server/src/server/test-utils/daemon-client.ts @@ -13,7 +13,7 @@ import type { AgentPersistenceHandle, AgentProvider, } from "../agent/agent-sdk-types.js"; -import { getAgentProviderDefinition } from "../agent/provider-manifest.js"; +import { getAgentProviderDefinition } from "../agent/provider-registry.js"; // ============================================================================ // Configuration diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 71ea15c24..160676652 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -84,8 +84,10 @@ export async function createTestPaseoDaemon( const close = async (): Promise => { await daemon.close().catch(() => undefined); - await rm(paseoHome, { recursive: true, force: true }); - await rm(staticDir, { recursive: true, force: true }); + // Wait a bit for file handles to release + await new Promise((r) => setTimeout(r, 200)); + await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }; return {