feat: integrate OpenCode as alternative coding agent#3
Conversation
Add support for OpenCode (https://github.com/anomalyco/opencode) as a second coding agent alongside Codex. Farfield can now route conversations to either backend based on a per-thread `agentKind` discriminator ("codex" | "opencode"). Key changes: - **packages/opencode-api/** — New workspace package wrapping `@opencode-ai/sdk`. Includes connection management, a service layer (create session, send message, list sessions, read session), and a mapper that translates OpenCode sessions / messages into the Codex protocol types Farfield already understands. 10 mapper unit tests included. - **apps/server/** — Server routes OpenCode traffic through the same REST+SSE surface used by Codex. Enable with `FARFIELD_OPENCODE_ENABLED=true`. Gracefully degrades when Codex CLI is not installed (detects ENOENT, disables Codex endpoints, auto-switches default agent to OpenCode). Thread-agent tracking via `agent-kind.ts` maps thread IDs to their backend. - **apps/web/** — Frontend is now agent-aware: agent selector in toolbar, "OC" badge on OpenCode threads, capability-gated UI (hides Plan/Model/Effort selectors for OpenCode threads), dynamic composer placeholder, and auto-creates a new thread on first send when none exists. - **Tests** — All 53 tests pass (mapper tests, protocol tests, web render test, server schema tests). Pre-existing jsdom mocks added for matchMedia, ResizeObserver, scrollTo. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Whoa this is sick. I'll take a closer look shortly. It would be nice if we could encapsulate different providers behind some abstract interface rather than mushing them all together, but we can clean that up later |
📝 WalkthroughWalkthroughThis pull request introduces dual-agent support by integrating the OpenCode AI agent alongside the existing Codex agent. A new Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant WebApp as Web App
participant Server
participant AgentRouter as Agent Router
participant Codex
participant OpenCode
User->>WebApp: Open application
WebApp->>Server: GET /api/agents
Server->>Server: Check available agents
Server-->>WebApp: {availableAgents, defaultAgent}
WebApp->>WebApp: Render agent selector
User->>WebApp: Select OpenCode agent & type message
WebApp->>WebApp: Set selectedAgentKind = "opencode"
alt No thread selected
WebApp->>Server: POST /api/threads with agentKind="opencode"
Server->>AgentRouter: Route to OpenCode
AgentRouter->>OpenCode: Create session
OpenCode-->>Server: sessionId
Server-->>WebApp: threadId, agentKind="opencode"
end
WebApp->>Server: POST /api/threads/{id}/messages with agentKind="opencode"
Server->>AgentRouter: Route message to OpenCode
AgentRouter->>OpenCode: Send prompt message
OpenCode->>OpenCode: Process & execute
OpenCode-->>Server: Session state update
Server-->>WebApp: Message response with agentKind
WebApp->>WebApp: Render message from OpenCode
User->>WebApp: Switch to Codex agent
WebApp->>WebApp: Set selectedAgentKind = "codex"
WebApp->>Server: POST /api/threads/{id}/messages with agentKind="codex"
Server->>AgentRouter: Route to Codex
AgentRouter->>Codex: Send message
Codex-->>Server: Response
Server-->>WebApp: Message response with agentKind="codex"
WebApp->>WebApp: Render message from Codex
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Comment |
|
Giving the branch a try. It works though I had to jump through some hoops to get it to pick OpenCode over Codex and find the right directory. Lemme try to make that smoother, then I'll merge this and layer my changes on top |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/App.tsx (1)
938-951:⚠️ Potential issue | 🟠 MajorDefault new thread creation to the selected agent.
createNewThreadat line 1028 is called from the sidebar without anagentKindparameter, causing threads to be created withagentKindundefined. This means the selected agent kind is ignored when users create threads via the sidebar, which will fail in OpenCode-only setups that don't support Codex. Default toselectedAgentKindinside the helper and add it to the dependency array.💡 Suggested fix
- const createNewThread = useCallback(async (projectPath: string, agentKind?: AgentKind) => { + const createNewThread = useCallback(async (projectPath: string, agentKind?: AgentKind) => { + const effectiveAgentKind = agentKind ?? selectedAgentKind; const trimmedProjectPath = projectPath.trim(); if (!trimmedProjectPath) { setError("Cannot create thread: missing project path"); return; } setIsBusy(true); try { setError(""); const created = await createThread({ cwd: trimmedProjectPath, - ...(agentKind ? { agentKind } : {}) + ...(effectiveAgentKind ? { agentKind: effectiveAgentKind } : {}) }); pendingMaterializationThreadIdsRef.current.add(created.threadId); setSelectedThreadId(created.threadId); selectedThreadIdRef.current = created.threadId; setMobileSidebarOpen(false); await refreshAll(); } catch (e) { setError(toErrorMessage(e)); } finally { setIsBusy(false); } - }, [refreshAll]); + }, [refreshAll, selectedAgentKind]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/web/src/App.tsx` around lines 938 - 951, createNewThread currently ignores the UI's selected agent when agentKind is undefined; update createNewThread to default agentKind to the outer selectedAgentKind (use something like const effectiveAgentKind = agentKind ?? selectedAgentKind) when building the payload for createThread, and add selectedAgentKind to the useCallback dependency array so the hook updates when the selection changes; reference the createNewThread function, the createThread call, and selectedAgentKind when making these changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/server/src/agent-kind.ts`:
- Around line 3-11: threadAgentMap currently stores agent-kind bindings only in
memory, so on restart resolveAgentKind falls back to "codex"; persist and
rehydrate that mapping. Modify registerThreadAgent to write the mapping to
durable storage (e.g., DB/file/Thread metadata) whenever called and change
module init/bootstrap to load persisted mappings into threadAgentMap during
startup (the same place that uses appClient.listThreads()). Ensure
resolveAgentKind reads from threadAgentMap as before but after rehydration so
threads retain their original AgentKind across restarts; reference
functions/vars: threadAgentMap, registerThreadAgent, resolveAgentKind, and the
bootstrap logic that calls appClient.listThreads().
In `@apps/server/src/index.ts`:
- Around line 926-935: When merging OpenCode sessions into mergedData after
openCodeService.listSessions(), register each session ID into the in-memory
OpenCode registry so isOpenCodeThread() will return true; iterate ocResult.data
(or ocData) and call the project’s OpenCode registration API (for example
registerOpenCodeThread(sessionId) or
openCodeSessionRegistry.register(sessionId)) for each session before setting
mergedData = [...codexData, ...ocData]; this ensures thread-specific endpoints
route to OpenCode after a server restart.
In `@apps/web/src/App.tsx`:
- Around line 1416-1420: The placeholder logic uses availableAgents when no
thread is selected, which can show "Message OpenCode…" even if the user has
chosen Codex; update the conditional to base the placeholder on the currently
selected agent state instead of agent availability: when selectedThreadId is
falsy, check the app's selected agent indicator (e.g., selectedAgent or
selectedAgentId / a boolean like isSelectedAgentOpenCode) and use
isActiveThreadOpenCode equivalent for that selected agent to decide between
"Message OpenCode…" and "Message Codex…"; keep the existing branch that uses
isActiveThreadOpenCode when a thread is selected.
- Around line 532-537: The current branch only updates availableAgents when
enabledAgents.length > 0, leaving stale values like ["codex"] when there are no
enabled agents; always call setAvailableAgents(enabledAgents) (not only on
length>0) and, if enabledAgents.length === 0, clear the current selection by
calling setSelectedAgentKind(null) (and keep setDefaultAgent(nag.defaultAgent)
if you still want to remember the default). Update the block around nag to use
setAvailableAgents(enabledAgents) unconditionally and explicitly handle the
empty-case by clearing selected agent so the composer shows the correct empty
state.
In `@packages/opencode-api/src/mapper.ts`:
- Around line 127-188: The current messagesToTurns function builds
assistantByParent as Map<string, AssistantMessage> which overwrites earlier
assistant messages when multiple assistant messages share the same parentID;
change this to collect all assistants per parentID (e.g., Map<string,
AssistantMessage[]>) or explicitly pick one deterministically (e.g., the latest
by assistantMsg.time.created) and document the choice; update the loop that
currently sets assistantByParent.set(msg.parentID, msg) to push into an array or
to compare timestamps and keep the newest, then adjust later lookup
(assistantByParent.get(userMsg.id)) and the logic that builds items, status,
error, finalAssistantStartedAtMs, turnId, etc., to either emit multiple turns
per user message when multiple assistants exist or to use the selected assistant
consistently (update references to assistantMsg accordingly) and add a short
comment in messagesToTurns explaining the chosen behavior.
In `@packages/opencode-api/src/service.ts`:
- Around line 97-112: In sendMessage, avoid mutating the outgoing message by
trimming only for validation: create a trimmed copy (e.g., const trimmed =
input.text.trim()) and throw when trimmed is empty, but pass the original
input.text to client.session.prompt; update the code paths in the sendMessage
method (OpenCodeSendMessageInput handling and the client.session.prompt body
parts) so the prompt uses the original string rather than the trimmed value.
---
Outside diff comments:
In `@apps/web/src/App.tsx`:
- Around line 938-951: createNewThread currently ignores the UI's selected agent
when agentKind is undefined; update createNewThread to default agentKind to the
outer selectedAgentKind (use something like const effectiveAgentKind = agentKind
?? selectedAgentKind) when building the payload for createThread, and add
selectedAgentKind to the useCallback dependency array so the hook updates when
the selection changes; reference the createNewThread function, the createThread
call, and selectedAgentKind when making these changes.
Summary
Adds OpenCode as a second coding agent backend alongside Codex. Farfield can now route conversations to either backend based on a per-thread
agentKinddiscriminator.@farfield/opencode-apipackage — wraps@opencode-ai/sdkwith connection management, a service layer, and a mapper that translates OpenCode sessions/messages into the Codex protocol types Farfield already usesFARFIELD_OPENCODE_ENABLED=true; gracefully degrades when Codex CLI is not installedHow to run
Prerequisites
http://localhost:47741)Development mode
Opens at
http://localhost:5173. The server starts on port 3578.Production mode
Opens at
http://localhost:3578.Environment variables
FARFIELD_OPENCODE_ENABLEDfalseOPENCODE_API_URLhttp://localhost:47741PORT3578Notes
Test plan
pnpm test) — mapper tests, protocol tests, web render test, server schema testspnpm buildsucceeds with no TypeScript errorsFARFIELD_OPENCODE_ENABLED=true pnpm dev, verify OpenCode threads can be created and messages sent🤖 Generated with Claude Code
Summary by CodeRabbit