feat: add opencode provider and refactor stream model to head/tail architecture

This commit is contained in:
Mohamed Boudra
2026-01-11 16:00:25 +07:00
parent 7fe10f9b4a
commit ebabcc09e5
42 changed files with 4416 additions and 2926 deletions
+9
View File
@@ -0,0 +1,9 @@
---
id: 1a4d69cf
title: OpenCode: Interruption test
status: done
deps: [d0dc7bd2]
created: 2026-01-11T04:30:18.687Z
parentId: 90e02e62
---
+9
View File
@@ -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
---
+332
View File
@@ -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 <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<void> {
// 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.
+92
View File
@@ -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.
+9
View File
@@ -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
---
+9
View File
@@ -0,0 +1,9 @@
---
id: d0dc7bd2
title: OpenCode: Basic session creation test
status: done
deps: []
created: 2026-01-11T04:29:55.160Z
parentId: 90e02e62
---
+9
View File
@@ -0,0 +1,9 @@
---
id: ea3f3266
title: OpenCode: Permission flow test
status: done
deps: [d0dc7bd2]
created: 2026-01-11T04:30:21.283Z
parentId: 90e02e62
---
+7 -10
View File
@@ -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",
+1 -2
View File
@@ -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"
}
}
@@ -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;
+18 -199
View File
@@ -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) => (
<Pressable
style={[styles.configRow, disabled && styles.configRowDisabled]}
onPress={onPress}
disabled={disabled}
>
<View style={styles.configTextGroup}>
<Text style={styles.configLabel}>{label}</Text>
<Text style={styles.configValue} numberOfLines={1}>
{value}
</Text>
{meta ? <Text style={styles.configMeta}>{meta}</Text> : null}
</View>
<ChevronRight size={18} color={theme.colors.mutedForeground} />
</Pressable>
),
[theme.colors.mutedForeground]
);
const pendingRequestIdRef = useRef<string | null>(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 (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
@@ -630,11 +589,18 @@ export default function HomeScreen() {
</Text>
</View>
)}
{renderConfigRow({
label: "Agent",
value: `${selectedProviderLabel} · ${selectedModel || "auto"} · ${selectedModeLabel}`,
onPress: () => openDropdownSheet("agent"),
})}
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
isModelLoading={isModelLoading}
onSelectModel={setModelFromUser}
/>
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<GitOptionsSection
useWorktree={useWorktree}
@@ -690,111 +656,6 @@ export default function HomeScreen() {
)}
</DropdownSheet>
<DropdownSheet
title="Agent"
visible={openDropdown === "agent"}
onClose={closeDropdown}
>
<View style={styles.agentSheetSection}>
<Text style={styles.agentSheetSectionLabel}>Provider</Text>
<View style={styles.dropdownSheetList}>
{providerDefinitions.map((definition) => {
const isSelected = definition.id === selectedProvider;
return (
<Pressable
key={definition.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
setProviderFromUser(definition.id);
}}
>
<Text style={styles.dropdownSheetOptionLabel}>
{definition.label}
</Text>
{definition.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{definition.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
<View style={styles.agentSheetSection}>
<Text style={styles.agentSheetSectionLabel}>Model</Text>
<View style={styles.dropdownSheetList}>
<Pressable
style={[
styles.dropdownSheetOption,
!selectedModel && styles.dropdownSheetOptionSelected,
]}
onPress={() => setModelFromUser("")}
>
<Text style={styles.dropdownSheetOptionLabel}>
Automatic (provider default)
</Text>
</Pressable>
{availableModels.map((model) => {
const isSelected = model.id === selectedModel;
return (
<Pressable
key={model.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => setModelFromUser(model.id)}
>
<Text style={styles.dropdownSheetOptionLabel}>
{model.label}
</Text>
{model.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{model.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
{modeOptions.length > 0 ? (
<View style={styles.agentSheetSection}>
<Text style={styles.agentSheetSectionLabel}>Mode</Text>
<View style={styles.dropdownSheetList}>
{modeOptions.map((mode) => {
const isSelected = mode.id === selectedMode;
return (
<Pressable
key={mode.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => setModeFromUser(mode.id)}
>
<Text style={styles.dropdownSheetOptionLabel}>
{mode.label}
</Text>
{mode.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{mode.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
</View>
) : null}
</DropdownSheet>
{errorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage}</Text>
@@ -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,
@@ -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<View>(null);
const inputRef = useRef<TextInput | null>(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 (
<>
<CompactSelectField
label={label}
value={displayValue}
placeholder={placeholder}
onPress={handleOpen}
disabled={disabled}
isLoading={isLoading}
controlRef={anchorRef}
/>
<AdaptiveSelect
title={title}
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
<TextInput
ref={inputRef}
style={styles.dropdownSearchInput}
placeholder={`Search ${label.toLowerCase()}...`}
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
/>
{showCustomOption ? (
<View style={styles.dropdownSheetList}>
<Pressable
style={styles.dropdownSheetOption}
onPress={() => handleSelect(sanitizedSearchValue)}
>
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
{`Use "${sanitizedSearchValue}"`}
</Text>
</Pressable>
</View>
) : null}
{hasMatches ? (
<View style={styles.dropdownSheetList}>
{filteredOptions.map((opt) => {
const isSelected = opt.id === value;
return (
<Pressable
key={opt.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => handleSelect(opt.id)}
>
<Text style={styles.dropdownSheetOptionLabel}>{opt.label}</Text>
{opt.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{opt.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
) : !showCustomOption ? (
<Text style={styles.helperText}>No options match your search.</Text>
) : null}
{isLoading ? (
<View style={styles.dropdownSheetLoading}>
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
</View>
) : null}
</AdaptiveSelect>
</>
);
}
interface CompactSelectFieldProps {
label: string;
value: string;
placeholder?: string;
onPress: () => void;
disabled?: boolean;
isLoading?: boolean;
controlRef?: React.RefObject<View | null>;
}
function CompactSelectField({
label,
value,
placeholder,
onPress,
disabled,
isLoading,
controlRef,
}: CompactSelectFieldProps): ReactElement {
return (
<Pressable
ref={controlRef}
onPress={onPress}
disabled={disabled}
style={[styles.compactSelectControl, disabled && styles.compactSelectControlDisabled]}
>
<Text style={styles.compactSelectLabel}>{label}</Text>
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.mutedForeground} />
) : (
<Text
style={value ? styles.compactSelectValue : styles.compactSelectPlaceholder}
numberOfLines={1}
>
{value || placeholder || "Select..."}
</Text>
)}
</Pressable>
);
}
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 (
<View style={styles.agentConfigRow}>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="PROVIDER"
title="Select Provider"
value={selectedProvider}
options={providerOptions}
placeholder="Select..."
disabled={disabled}
onSelect={onSelectProvider}
/>
</View>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="MODEL"
title="Select Model"
value={selectedModel}
options={modelSelectOptions}
placeholder="Auto"
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
/>
</View>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="MODE"
title="Select Mode"
value={effectiveSelectedMode}
options={modeSelectOptions}
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
/>
</View>
</View>
);
}
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,
},
}));
+209 -56
View File
@@ -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 = <AgentThoughtMessage message={item.text} status={item.status} />;
console.log("[AgentStreamView] renderStreamItem", { item });
content = (
<AgentThoughtMessage message={item.text} status={item.status} />
);
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 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} ws={wsOrInert} />
<PermissionRequestCard
key={permission.key}
permission={permission}
ws={wsOrInert}
/>
))}
</View>
) : null}
@@ -366,19 +390,32 @@ export function AgentStreamView({
</View>
) : null}
{hasStreaming ? (
<View style={stylesheet.streamItemWrapper}>
<AssistantMessage
message={streamingBuffer.text}
timestamp={streamingBuffer.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
/>
</View>
) : null}
{hasHeadItems
? [...streamHead].reverse().map((item) => {
const rendered = renderStreamItem({
item,
index: 0,
separators: {
highlight: () => {},
unhighlight: () => {},
updateProps: () => {},
},
});
return rendered ? (
<View key={item.id}>{rendered}</View>
) : null;
})
: null}
</View>
</View>
);
}, [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 = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -687,7 +732,11 @@ function PermissionRequestCard({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -709,7 +758,11 @@ function PermissionRequestCard({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
selectable
>
{node.content}
</Text>
),
@@ -752,7 +805,10 @@ function PermissionRequestCard({
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{bullet}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]} selectable>
<Text
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
selectable
>
{children}
</Text>
</View>
@@ -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({
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>
<Text
style={[permissionStyles.title, { color: theme.colors.foreground }]}
>
{title}
</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.mutedForeground }]}>
<Text
style={[
permissionStyles.description,
{ color: theme.colors.mutedForeground },
]}
>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Proposed Plan</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Proposed Plan
</Text>
<View
style={[
permissionStyles.contentCard,
@@ -837,26 +910,59 @@ function PermissionRequestCard({
},
]}
>
<Markdown style={markdownStyles} rules={markdownRules}>{planMarkdown}</Markdown>
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
</View>
) : null}
{commandDetails ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Command</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Command
</Text>
{commandDetails.command ? (
<View style={permissionStyles.metadataRow}>
<Text style={[permissionStyles.metadataLabel, { color: theme.colors.mutedForeground }]}>Command</Text>
<Text style={[permissionStyles.metadataValue, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground },
]}
>
Command
</Text>
<Text
style={[
permissionStyles.metadataValue,
{ color: theme.colors.foreground },
]}
>
{commandDetails.command}
</Text>
</View>
) : null}
{commandDetails.cwd ? (
<View style={permissionStyles.metadataRow}>
<Text style={[permissionStyles.metadataLabel, { color: theme.colors.mutedForeground }]}>Directory</Text>
<Text style={[permissionStyles.metadataValue, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground },
]}
>
Directory
</Text>
<Text
style={[
permissionStyles.metadataValue,
{ color: theme.colors.foreground },
]}
>
{commandDetails.cwd}
</Text>
</View>
@@ -866,9 +972,19 @@ function PermissionRequestCard({
{editEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Proposed Changes</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Proposed Changes
</Text>
{editEntries.map((entry, index) => (
<View key={`${entry.filePath ?? "change"}-${index}`} style={permissionStyles.diffSection}>
<View
key={`${entry.filePath ?? "change"}-${index}`}
style={permissionStyles.diffSection}
>
{entry.filePath ? (
<View
style={[
@@ -879,7 +995,12 @@ function PermissionRequestCard({
},
]}
>
<Text style={[permissionStyles.fileBadgeText, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.fileBadgeText,
{ color: theme.colors.foreground },
]}
>
{entry.filePath}
</Text>
</View>
@@ -902,7 +1023,14 @@ function PermissionRequestCard({
{readEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>File Content</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
File Content
</Text>
{readEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "content"}-${index}`}
@@ -918,13 +1046,21 @@ function PermissionRequestCard({
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.mutedForeground, marginBottom: theme.spacing[1] },
{
color: theme.colors.mutedForeground,
marginBottom: theme.spacing[1],
},
]}
>
{entry.filePath}
</Text>
) : null}
<Text style={[permissionStyles.rawContentText, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.rawContentText,
{ color: theme.colors.foreground },
]}
>
{entry.content}
</Text>
</View>
@@ -934,7 +1070,14 @@ function PermissionRequestCard({
{inputPreview ? (
<View style={permissionStyles.section}>
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.mutedForeground }]}>Raw Request</Text>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.mutedForeground },
]}
>
Raw Request
</Text>
<View
style={[
permissionStyles.contentCard,
@@ -944,7 +1087,12 @@ function PermissionRequestCard({
},
]}
>
<Text style={[permissionStyles.rawContentText, { color: theme.colors.foreground }]}>
<Text
style={[
permissionStyles.rawContentText,
{ color: theme.colors.foreground },
]}
>
{inputPreview}
</Text>
</View>
@@ -970,7 +1118,10 @@ function PermissionRequestCard({
disabled={isResponding}
>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.primaryForeground} />
<ActivityIndicator
size="small"
color={theme.colors.primaryForeground}
/>
) : (
<Text
style={[
@@ -996,7 +1147,10 @@ function PermissionRequestCard({
disabled={isResponding}
>
{isResponding ? (
<ActivityIndicator size="small" color={theme.colors.primaryForeground} />
<ActivityIndicator
size="small"
color={theme.colors.primaryForeground}
/>
) : (
<Text
style={[
@@ -1013,7 +1167,6 @@ function PermissionRequestCard({
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
File diff suppressed because it is too large Load Diff
+52 -34
View File
@@ -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<ParsedDiffFile> = useCallback(
({ item, index }) => (
<DiffFileSection file={item} testID={`diff-file-${index}`} />
),
[]
);
const keyExtractor = useCallback(
(item: ParsedDiffFile, index: number) => `${item.path}-${index}`,
[]
);
if (!agentExists) {
return (
<View style={styles.errorContainer}>
@@ -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 (
<ScrollView
style={styles.scrollView}
testID="git-diff-scroll"
refreshControl={
<RefreshControl
refreshing={isManualRefresh && isFetching}
onRefresh={handleRefresh}
tintColor={theme.colors.mutedForeground}
colors={[theme.colors.primary]}
/>
}
>
<View style={styles.contentContainer} testID="git-diff-content">
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Loading changes...</Text>
</View>
) : isError ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
</View>
) : !hasChanges ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No changes</Text>
</View>
) : (
files.map((file, fileIndex) => (
<DiffFileSection key={fileIndex} file={file} testID={`diff-file-${fileIndex}`} />
))
)}
if (isLoading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>Loading changes...</Text>
</View>
</ScrollView>
);
}
if (isError) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
</View>
);
}
if (!hasChanges) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No changes</Text>
</View>
);
}
return (
<FlatList
data={files}
renderItem={renderFileSection}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onRefresh={handleRefresh}
refreshing={isManualRefresh && isFetching}
initialNumToRender={3}
maxToRenderPerBatch={3}
windowSize={5}
/>
);
}
+125 -40
View File
@@ -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<string | null>(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 = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -371,7 +388,11 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -402,7 +423,10 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<Text
key={node.key}
style={[inheritedStyles, assistantMessageStylesheet.markdownCodeInline]}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
]}
selectable
>
{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}
</Text>
@@ -460,7 +487,9 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
@@ -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({
<View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}>
<Text style={todoListCardStylesheet.title}>Plan</Text>
<Text style={todoListCardStylesheet.timestamp}>{timestampLabel}</Text>
<Text style={todoListCardStylesheet.timestamp}>
{timestampLabel}
</Text>
</View>
<View style={todoListCardStylesheet.providerBadge}>
<Text style={todoListCardStylesheet.providerText}>{providerLabel}</Text>
<Text style={todoListCardStylesheet.providerText}>
{providerLabel}
</Text>
</View>
</View>
<Text style={todoListCardStylesheet.progressText}>
@@ -809,7 +845,10 @@ export const TodoListCard = memo(function TodoListCard({
</Text>
) : (
items.map((item, idx) => (
<View key={`${item.text}-${idx}`} style={todoListCardStylesheet.itemRow}>
<View
key={`${item.text}-${idx}`}
style={todoListCardStylesheet.itemRow}
>
<View
style={[
todoListCardStylesheet.checkbox,
@@ -889,10 +928,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
};
}, [isLoading, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const spin = useMemo(
() =>
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,
]}
>
<View style={expandableBadgeStylesheet.headerRow}>
@@ -930,7 +975,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
{label}
</Text>
{secondaryLabel ? (
<Text style={expandableBadgeStylesheet.secondaryLabel} numberOfLines={1}>
<Text
style={expandableBadgeStylesheet.secondaryLabel}
numberOfLines={1}
>
{secondaryLabel}
</Text>
) : (
@@ -948,7 +996,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{detailContent ? (
<View style={expandableBadgeStylesheet.detailWrapper}>{detailContent}</View>
<View style={expandableBadgeStylesheet.detailWrapper}>
{detailContent}
</View>
) : null}
</Pressable>
</View>
@@ -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 = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -1007,7 +1064,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -1029,7 +1090,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
selectable
>
{node.content}
</Text>
),
@@ -1072,7 +1137,9 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
@@ -1083,7 +1150,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
const renderDetails = useCallback(() => {
if (!markdownContent) {
return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>;
return (
<Text style={agentThoughtStylesheet.emptyText}>
No captured thinking
</Text>
);
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
@@ -1124,14 +1195,16 @@ const toolKindIcons: Record<string, any> = {
// 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 (
<View style={toolCallInlineStyles.detailsContainer}>
<ToolCallDetailsContent display={display} errorText={errorText} maxHeight={400} />
<ToolCallDetailsContent
display={display}
errorText={errorText}
maxHeight={400}
/>
</View>
);
}, [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"}
/>
File diff suppressed because it is too large Load Diff
@@ -131,6 +131,8 @@ export function useAgentFormState(
setSelectedProvider(provider);
userEditedPreferencesRef.current.model = true;
setSelectedModel("");
userEditedPreferencesRef.current.mode = true;
setSelectedMode("");
}, []);
const setModeFromUser = useCallback((modeId: string) => {
+26 -33
View File
@@ -212,9 +212,9 @@ export interface SessionState {
messages: MessageEntry[];
currentAssistantMessage: string;
// Stream state
agentStreamState: Map<string, StreamItem[]>;
agentStreamingBuffer: Map<string, { id: string; text: string; timestamp: Date }>;
// Stream state (head/tail model)
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
// Initializing agents
initializingAgents: Map<string, boolean>;
@@ -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<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
setAgentStreamingBuffer: (
serverId: string,
state:
| Map<string, { id: string; text: string; timestamp: Date }>
| ((
prev: Map<string, { id: string; text: string; timestamp: Date }>
) => Map<string, { id: string; text: string; timestamp: Date }>)
) => void;
clearAgentStreamingBuffer: (serverId: string, agentId: string) => void;
// Stream state (head/tail model)
setAgentStreamTail: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
setAgentStreamHead: (serverId: string, state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
clearAgentStreamHead: (serverId: string, agentId: string) => void;
// Initializing agents
setInitializingAgents: (serverId: string, state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>)) => 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<SessionStore>()(
});
},
// 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 },
},
};
});
+112 -2
View File
@@ -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");
});
});
+277 -105
View File
@@ -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<StreamItem[]>((state, { event, timestamp }) => {
return reduceStreamUpdate(state, event, timestamp);
}, []);
const hydrated = events.reduce<StreamItem[]>(
(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<StreamItem["kind"]>([
"assistant_message",
"thought",
]);
function isStreamableKind(kind: StreamItem["kind"]): kind is StreamableKind {
return STREAMABLE_KINDS.has(kind);
}
const STREAM_COMPLETION_EVENTS = new Set<AgentStreamEventPayload["type"]>([
"turn_completed",
@@ -809,24 +852,190 @@ const STREAM_COMPLETION_EVENTS = new Set<AgentStreamEventPayload["type"]>([
"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 };
}
+1
View File
@@ -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",
@@ -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<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
}
@@ -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";
@@ -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
);
});
@@ -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<AgentModelDefinition[]> {
if (provider === "claude") {
return fetchClaudeModelCatalog(options);
}
if (provider === "codex") {
return fetchCodexModelCatalog();
}
throw new Error(`Unsupported provider '${provider}'`);
}
export async function fetchClaudeModelCatalog(
options?: ProviderModelCatalogOptions
): Promise<AgentModelDefinition[]> {
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<AgentModelDefinition[]> {
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<SDKUserMessage> {
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<typeof setTimeout>;
};
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<number, PendingRequest>();
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<unknown> {
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<void> {
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();
}
}
@@ -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(() => {
@@ -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];
@@ -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<string, number>();
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<string, number>();
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
);
});
@@ -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);
}
@@ -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<AgentModelDefinition[]>;
}
const claudeClient = new ClaudeAgentClient();
const codexClient = new CodexMcpAgentClient();
const opencodeClient = new OpenCodeAgentClient();
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = {
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<AgentProvider, AgentClient> {
const clients: Partial<Record<AgentProvider, AgentClient>> = {};
for (const [id, definition] of Object.entries(PROVIDER_REGISTRY)) {
clients[id as AgentProvider] = definition.createClient();
}
return clients as Record<AgentProvider, AgentClient>;
}
export async function fetchProviderModels(
provider: AgentProvider,
options?: { cwd?: string }
): Promise<AgentModelDefinition[]> {
const definition = getProviderDefinition(provider);
return definition.fetchModels(options);
}
@@ -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
);
});
@@ -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<AgentModelDefinition[]> {
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<PersistedAgentDescriptor[]> {
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const projectsRoot = path.join(configDir, "projects");
@@ -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
);
});
@@ -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<AgentModelDefinition[]> {
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<typeof setTimeout>;
};
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<string, unknown>;
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<string, unknown>;
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<number, CodexPendingRequest>();
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<unknown> {
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<void> {
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();
}
}
// ============================================================================
@@ -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<AgentStreamEvent>
): Promise<TurnResult> {
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<AgentStreamEvent, { type: "timeline" }> => 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
);
});
@@ -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<number> {
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<void> {
if (this.server && !this.server.killed) {
this.server.kill("SIGTERM");
await new Promise<void>((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<AgentSession> {
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<AgentSessionConfig>
): Promise<AgentSession> {
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<AgentModelDefinition[]> {
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<PersistedAgentDescriptor[]> {
// 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<string, AgentPermissionRequest>();
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<string, "user" | "assistant">();
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<AgentRuntimeInfo> {
return {
provider: "opencode",
sessionId: this.sessionId,
model: this.config.model ?? null,
modeId: this.currentMode,
};
}
async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
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<AgentStreamEvent> {
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<void> {
this.abortController?.abort();
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
});
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
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<AgentMode[]> {
return DEFAULT_MODES;
}
async getCurrentMode(): Promise<string | null> {
return this.currentMode;
}
async setMode(modeId: string): Promise<void> {
this.currentMode = modeId;
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
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<void> {
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;
}
}
+2 -4
View File
@@ -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,
+1 -4
View File
@@ -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<AgentMode> = z.object({
+8 -11
View File
@@ -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<string>();
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
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<void> {
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({
@@ -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
@@ -84,8 +84,10 @@ export async function createTestPaseoDaemon(
const close = async (): Promise<void> => {
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 {