12 KiB
Generic ACP Runner
Overview
Create a clean, generic ACP agent runner that starts any ACP-compatible CLI from a command + args and communicates via ACP protocol. The runner maps ACP events to the new session protocol (envelopes) through a stateful handler class. No vendor-specific hacks. No credentials/env/API key resolution. No session restarts. No conversation history. Just: command in, session protocol out.
This enables support for Gemini, OpenCode, and any future ACP agent without writing per-agent runners.
Context
- Existing AcpBackend (
agent/acp/AcpBackend.ts) already handles process spawning, ACP JSON-RPC, permissions, and tool tracking. Reused as-is. - Existing runGemini.ts (~1300 lines) is vendor-specific. Stays untouched; migration happens later.
- Codex already uses new session protocol via
mapCodexMcpMessageToSessionEnvelopes()— reference pattern. - Session protocol types in
@slopus/happy-wire(SessionEnvelope,createEnvelope()). - AgentMessage is the event type emitted by
AcpBackend.onMessage(). - The runner does NOT resolve credentials, API keys, OAuth tokens, or environment variables. The user's shell environment is inherited as-is. If
geminineedsGEMINI_API_KEY, the user sets it before running.
Development Approach
- Testing approach: Regular (code first, then tests)
- Complete each task fully before moving to the next
- CRITICAL: every task MUST include new/updated tests
- CRITICAL: all tests must pass before starting next task
Testing Strategy
- Unit tests: Required for every task
- Test the stateful event mapper thoroughly (it's the core logic)
Progress Tracking
- Mark completed items with
[x]immediately when done - Add newly discovered tasks with + prefix
- Document issues/blockers with !! prefix
Implementation Steps
Task 1: Create AcpSessionMapper class
The stateful handler that maps AgentMessage events from AcpBackend into SessionEnvelope[] for the new session protocol. This is the core logic.
File: packages/happy-cli/src/agent/acp/AcpSessionMapper.ts
Class design:
class AcpSessionMapper {
private currentTurnId: string | null = null;
// Called when agent emits a message. Returns envelopes to send.
mapMessage(msg: AgentMessage): SessionEnvelope[]
}
Mapping rules (AgentMessage type -> SessionEnvelope events):
status: 'running'(first time per turn) ->turn-startenvelope (creates new turnId)status: 'idle'orstatus: 'stopped'(when turn is active) ->turn-end { status: 'completed' }status: 'error'(when turn is active) ->turn-end { status: 'failed' }model-output { textDelta }->text { text: textDelta }tool-call->tool-call-start { call, name, title, description, args }tool-result->tool-call-end { call }event { name: 'thinking' }->text { text, thinking: true }permission-request,permission-response,token-count-> ignored (handled elsewhere)
Key behaviors:
- Turn lifecycle:
runningstarts a turn,idle/stopped/errorends it - No turn nesting — only one turn active at a time
- Idempotent: multiple
runningstatuses don't create multiple turns - Multiple
idlestatuses don't create multipleturn-ends
ID generation:
turnIDs: generated as cuid2 viacreateId()onturn-startcallIDs intool-call-start/tool-call-end: generated as cuid2, mapped from the ACP backend'scallId- Envelope
idfields: generated as cuid2 viacreateEnvelope()(automatic) subagentIDs: cuid2 (future, not needed for v1)
Since all IDs are non-deterministic cuid2, tests must:
- Assert structural shape (correct
ev.t, correct fields present) - Assert ID consistency (same
turnacross all envelopes in a turn, samecallin start/end pairs) - Assert ID format (valid cuid2)
- NOT assert exact ID values
Test file: packages/happy-cli/src/agent/acp/AcpSessionMapper.test.ts
Test cases for turn lifecycle:
running-> emits 1 envelope:turn-startwith cuid2turnrunningthenidle-> emitsturn-startthenturn-end { status: 'completed' }, both share sameturnrunningthenerror->turn-startthenturn-end { status: 'failed' }, sameturnrunningthenstopped->turn-startthenturn-end { status: 'cancelled' }, sameturnrunning, running-> only 1turn-start(idempotent)idlewithout priorrunning-> no envelopes (no turn to end)idle, idle-> only 1turn-end(idempotent)running, idle, running, idle-> 2 complete turn cycles, each with differentturncuid2startingstatus -> ignored (no envelopes)
Test cases for text mapping:
model-output { textDelta }during active turn ->text { text }envelope with correctturnmodel-outputwithout active turn ->textenvelope withoutturn(or auto-start turn — TBD)- Empty
textDelta-> no envelope
Test cases for tool call mapping:
tool-call { callId, toolName, args }->tool-call-start { call, name, title, description, args }with cuid2calltool-result { callId }->tool-call-end { call }wherecallmatches the cuid2 mapped from samecallId- Multiple tool calls -> each gets distinct cuid2
call, correctly paired start/end tool-resultfor unknowncallId-> still emitstool-call-endwith new cuid2- All tool envelopes carry the current
turn
Test cases for thinking:
event { name: 'thinking', payload: { text } }->text { text, thinking: true }with currentturn- Thinking with empty text -> no envelope
Test cases for ignored messages:
permission-request-> no envelopespermission-response-> no envelopestoken-count-> no envelopesfs-edit-> no envelopesterminal-output-> no envelopes
Test cases for ID consistency across a full sequence:
-
Simulate full turn:
running->model-output->tool-call->tool-result->model-output->idle -
Assert all envelopes share same
turncuid2 -
Assert
tool-call-start.callmatchestool-call-end.call -
Assert all
idfields are unique cuid2 -
Assert
turnchanges between separate turns -
Create
AcpSessionMapperclass withmapMessage()method -
Implement turn lifecycle (turn-start on running, turn-end on idle/error)
-
Implement text mapping (model-output -> text event)
-
Implement tool call mapping (tool-call -> tool-call-start, tool-result -> tool-call-end) with cuid2 call ID mapping
-
Implement thinking mapping (event/thinking -> text with thinking: true)
-
Write tests for turn lifecycle (all cases above)
-
Write tests for text/tool/thinking mapping (all cases above)
-
Write tests for ignored messages
-
Write tests for ID consistency across full turn sequence
-
Write tests for edge cases (multiple idles, running without idle, etc.)
-
Run tests - must pass before next task
Task 2: Create generic runAcp runner function
The runner function that wires everything together: creates AcpBackend, listens for messages, maps them through AcpSessionMapper, and sends them to the session.
File: packages/happy-cli/src/agent/acp/runAcp.ts
Signature:
async function runAcp(opts: {
credentials: Credentials;
agentName: string; // e.g. 'gemini', 'opencode'
command: string; // e.g. 'gemini'
args: string[]; // e.g. ['--experimental-acp']
startedBy?: 'daemon' | 'terminal';
}): Promise<void>
No credentials/env resolution — the command is run with the user's inherited shell environment. No GEMINI_API_KEY, no OAuth tokens, no model resolution. The user sets their env before running.
What it does (simplified flow):
- Create API session (same pattern as runGemini but simpler)
- Start Happy MCP server for tool bridge
- Create AcpBackend with DefaultTransport (no vendor-specific transport)
- Create AcpSessionMapper
- Wire:
backend.onMessage(msg => mapper.mapMessage(msg).forEach(env => session.sendSessionProtocolMessage(env))) - Start ACP session
- Listen for user messages from session, forward to backend via
sendPrompt() - Handle abort/kill session
- Simple console logging (no Ink)
- Clean up on exit
- Create
runAcp()function with session setup (API client, machine, session creation) - Wire AcpBackend + AcpSessionMapper + session protocol message sending
- Implement user message handling (session.onUserMessage -> messageQueue -> sendPrompt)
- Implement abort/kill session handlers
- Implement permission handling via AcpPermissionHandler interface
- Add simple console logging for status (no Ink)
- Add cleanup/dispose logic
- Write tests for runner setup and message flow (with mocked backend)
- Run tests - must pass before next task
Task 3: Register agents and add CLI commands
Wire the generic runner into the CLI so users can run happy acp gemini or happy acp opencode or happy acp -- custom-agent --flag.
Files:
packages/happy-cli/src/index.ts— add CLI command routing- Agent configs for known agents (command + args only, no env/credentials)
Agent config:
const KNOWN_ACP_AGENTS: Record<string, { command: string; args: string[] }> = {
gemini: { command: 'gemini', args: ['--experimental-acp'] },
opencode: { command: 'opencode', args: ['--acp'] },
};
No env vars, no API keys, no model config. Just command + args.
- Define known ACP agent configs (command + args only)
- Add CLI routing for
happy acp <agent-name>andhappy acp -- <cmd> [args] - Wire to
runAcp()with resolved config - Write tests for agent config resolution
- Run tests - must pass before next task
Task 4: Verify acceptance criteria
- Verify generic runner works without vendor-specific code
- Verify no credentials/env/API key resolution anywhere in the new code
- Verify new session protocol envelopes are emitted correctly
- Verify sessions are never restarted (only in-memory state)
- Verify permission handling works
- Run full test suite (unit tests)
- Run linter - all issues must be fixed
Task 5: [Final] Update documentation
- Update README.md if needed
- Add inline comments explaining the architecture
Technical Details
AcpSessionMapper State Machine
[no turn] ---(status: running)---> [turn active]
[turn active] ---(status: idle)---> [no turn] (emit turn-end: completed)
[turn active] ---(status: error)---> [no turn] (emit turn-end: failed)
[turn active] ---(status: stopped)---> [no turn] (emit turn-end: cancelled)
All content events (text, tool-call-start, tool-call-end, thinking) are emitted with the current turnId set on the envelope.
No Credentials / No Env Resolution
The runner inherits the user's shell environment. Period. If the agent needs GEMINI_API_KEY or OPENAI_API_KEY, the user sets it in their shell. The runner doesn't know or care about vendor-specific env vars.
No Session Restarts
The ACP protocol supports model switching natively. The process stays alive for the entire CLI session. No dispose-and-recreate. No conversation history injection.
DefaultTransport
The generic runner uses DefaultTransport which:
- 60s init timeout
- Filters non-JSON stdout lines
- No stderr error detection
- No tool name extraction hacks
If a specific agent needs custom transport behavior, it can be added later — but the runner stays generic.
Post-Completion
Manual verification:
- Test with
gemini --experimental-acpto verify ACP protocol works - Test with
opencode --acpwhen available - Verify mobile app receives session protocol envelopes correctly
Future work:
- Migrate
runGemini.tsto use generic runner as thin wrapper - Add agent-specific transport handlers only when proven necessary