docs/plans/generic-acp-runner.md
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.
agent/acp/AcpBackend.ts) already handles process spawning, ACP JSON-RPC, permissions, and tool tracking. Reused as-is.mapCodexMcpMessageToSessionEnvelopes() — reference pattern.@slopus/happy-wire (SessionEnvelope, createEnvelope()).AcpBackend.onMessage().gemini needs GEMINI_API_KEY, the user sets it before running.[x] immediately when doneThe 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-start envelope (creates new turnId)status: 'idle' or status: '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:
running starts a turn, idle/stopped/error ends itrunning statuses don't create multiple turnsidle statuses don't create multiple turn-endsID generation:
turn IDs: generated as cuid2 via createId() on turn-startcall IDs in tool-call-start / tool-call-end: generated as cuid2, mapped from the ACP backend's callIdid fields: generated as cuid2 via createEnvelope() (automatic)subagent IDs: cuid2 (future, not needed for v1)Since all IDs are non-deterministic cuid2, tests must:
ev.t, correct fields present)turn across all envelopes in a turn, same call in start/end pairs)Test file: packages/happy-cli/src/agent/acp/AcpSessionMapper.test.ts
Test cases for turn lifecycle:
running -> emits 1 envelope: turn-start with cuid2 turnrunning then idle -> emits turn-start then turn-end { status: 'completed' }, both share same turnrunning then error -> turn-start then turn-end { status: 'failed' }, same turnrunning then stopped -> turn-start then turn-end { status: 'cancelled' }, same turnrunning, running -> only 1 turn-start (idempotent)idle without prior running -> no envelopes (no turn to end)idle, idle -> only 1 turn-end (idempotent)running, idle, running, idle -> 2 complete turn cycles, each with different turn cuid2starting status -> ignored (no envelopes)Test cases for text mapping:
model-output { textDelta } during active turn -> text { text } envelope with correct turnmodel-output without active turn -> text envelope without turn (or auto-start turn — TBD)textDelta -> no envelopeTest cases for tool call mapping:
tool-call { callId, toolName, args } -> tool-call-start { call, name, title, description, args } with cuid2 calltool-result { callId } -> tool-call-end { call } where call matches the cuid2 mapped from same callIdcall, correctly paired start/endtool-result for unknown callId -> still emits tool-call-end with new cuid2turnTest cases for thinking:
event { name: 'thinking', payload: { text } } -> text { text, thinking: true } with current turnTest cases for ignored messages:
permission-request -> no envelopespermission-response -> no envelopestoken-count -> no envelopesfs-edit -> no envelopesterminal-output -> no envelopesTest 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 turn cuid2
Assert tool-call-start.call matches tool-call-end.call
Assert all id fields are unique cuid2
Assert turn changes between separate turns
Create AcpSessionMapper class with mapMessage() 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
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):
backend.onMessage(msg => mapper.mapMessage(msg).forEach(env => session.sendSessionProtocolMessage(env)))sendPrompt()runAcp() function with session setup (API client, machine, session creation)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 routingAgent 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.
happy acp <agent-name> and happy acp -- <cmd> [args]runAcp() with resolved config[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.
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.
The ACP protocol supports model switching natively. The process stays alive for the entire CLI session. No dispose-and-recreate. No conversation history injection.
The generic runner uses DefaultTransport which:
If a specific agent needs custom transport behavior, it can be added later — but the runner stays generic.
Manual verification:
gemini --experimental-acp to verify ACP protocol worksopencode --acp when availableFuture work:
runGemini.ts to use generic runner as thin wrapper