feat: add opencode provider and refactor stream model to head/tail architecture
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: 1a4d69cf
|
||||
title: OpenCode: Interruption test
|
||||
status: done
|
||||
deps: [d0dc7bd2]
|
||||
created: 2026-01-11T04:30:18.687Z
|
||||
parentId: 90e02e62
|
||||
---
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: d0dc7bd2
|
||||
title: OpenCode: Basic session creation test
|
||||
status: done
|
||||
deps: []
|
||||
created: 2026-01-11T04:29:55.160Z
|
||||
parentId: 90e02e62
|
||||
---
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: ea3f3266
|
||||
title: OpenCode: Permission flow test
|
||||
status: done
|
||||
deps: [d0dc7bd2]
|
||||
created: 2026-01-11T04:30:21.283Z
|
||||
parentId: 90e02e62
|
||||
---
|
||||
|
||||
Generated
+7
-10
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user