diff --git a/bin/opencode-acp-adapter.mjs b/bin/opencode-acp-adapter.mjs new file mode 100755 index 000000000..159388ed4 --- /dev/null +++ b/bin/opencode-acp-adapter.mjs @@ -0,0 +1,496 @@ +#!/usr/bin/env node +// OpenCode ACP-to-HTTP adapter (#1243). +// +// A single-purpose shim that speaks ACP (Agent Client Protocol, +// JSON-RPC 2.0 over stdio, newline-delimited) on one side and the +// OpenCode serve HTTP API on the other, so an ACP client (OpenClaw's +// acpx runtime) drives the fleet's ONE standing shared `opencode serve` +// instead of `opencode acp`, which starts a private server per process. +// +// Mapping (per #1243): +// ACP initialize -> static capabilities (no server call) +// ACP session/new -> POST /session (cwd -> ?directory=) +// ACP session/prompt -> POST /session/:id/prompt_async, then the +// GET /event SSE stream is translated back +// into ACP session/update notifications; +// the prompt resolves on session.idle +// serve permission.updated -> ACP session/request_permission; the +// client's choice is posted back to +// POST /session/:id/permissions/:permId +// ACP session/cancel -> POST /session/:id/abort +// +// Config: +// --server shared serve base URL +// (env OPENCODE_ACP_SERVER; default +// http://127.0.0.1:13420 — the standing +// shared server OpenChamber attaches to) +// OPENCODE_SERVER_PASSWORD when set, sent as HTTP basic auth +// (user "opencode"), matching serve's auth +// OPENCODE_ACP_ADAPTER_DEBUG=1 -> diagnostics on stderr (stdout is +// the protocol channel and stays clean) +// +// The adapter spawns no server of its own; if the shared serve is +// unreachable, session/new fails with the connection error. + +import { createInterface } from 'node:readline'; +import process from 'node:process'; + +const PROTOCOL_VERSION = 1; +const AGENT_NAME = 'opencode-acp-adapter'; +const AGENT_VERSION = '0.1.0'; + +const debug = (...args) => { + if (process.env.OPENCODE_ACP_ADAPTER_DEBUG === '1') { + process.stderr.write(`[${AGENT_NAME}] ${args.join(' ')}\n`); + } +}; + +function parseServerUrl(argv) { + let server = process.env.OPENCODE_ACP_SERVER || 'http://127.0.0.1:13420'; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--server' && argv[i + 1]) { + server = argv[i + 1]; + i += 1; + } else if (argv[i].startsWith('--server=')) { + server = argv[i].slice('--server='.length); + } + } + return server.replace(/\/+$/, ''); +} + +const serverBase = parseServerUrl(process.argv.slice(2)); + +function authHeaders(extra = {}) { + const headers = { ...extra }; + const password = process.env.OPENCODE_SERVER_PASSWORD; + if (password) { + headers.Authorization = `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`; + } + return headers; +} + +async function serveJson(pathname, { method = 'GET', body } = {}) { + const res = await fetch(`${serverBase}${pathname}`, { + method, + headers: authHeaders(body === undefined ? {} : { 'Content-Type': 'application/json' }), + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`serve ${method} ${pathname} -> ${res.status} ${text.slice(0, 200)}`); + } + if (res.status === 204) return null; + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +const withDirectory = (pathname, directory) => + directory ? `${pathname}?directory=${encodeURIComponent(directory)}` : pathname; + +// --------------------------------------------------------------------------- +// JSON-RPC peer over stdio +// --------------------------------------------------------------------------- + +let nextOutgoingId = 1; +const pendingClientRequests = new Map(); // id -> { resolve, reject } + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function sendResult(id, result) { + send({ jsonrpc: '2.0', id, result }); +} + +function sendError(id, code, message) { + send({ jsonrpc: '2.0', id, error: { code, message } }); +} + +function sendNotification(method, params) { + send({ jsonrpc: '2.0', method, params }); +} + +function requestClient(method, params) { + const id = nextOutgoingId; + nextOutgoingId += 1; + return new Promise((resolve, reject) => { + pendingClientRequests.set(id, { resolve, reject }); + send({ jsonrpc: '2.0', id, method, params }); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const sessions = new Map(); // acpSessionId -> { directory } +const pendingPrompts = new Map(); // acpSessionId -> { requestId } +const cancelledSessions = new Set(); // acpSessionId +const partTextSent = new Map(); // partId -> chars of part.text already streamed + +function sessionUpdate(sessionId, update) { + sendNotification('session/update', { sessionId, update }); +} + +// --------------------------------------------------------------------------- +// Serve event stream (GET /event, SSE) +// --------------------------------------------------------------------------- + +let eventStreamReady = null; +let eventStreamReadyResolve = null; +let shuttingDown = false; + +function markEventStreamReady() { + if (eventStreamReadyResolve) { + eventStreamReadyResolve(); + eventStreamReadyResolve = null; + } +} + +async function ensureEventStream(timeoutMs = 5000) { + if (!eventStreamReady) return; + await Promise.race([ + eventStreamReady, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + +async function runEventStream() { + eventStreamReady = new Promise((resolve) => { + eventStreamReadyResolve = resolve; + }); + while (!shuttingDown) { + try { + const res = await fetch(`${serverBase}/event`, { + headers: authHeaders({ Accept: 'text/event-stream' }), + }); + if (!res.ok || !res.body) throw new Error(`GET /event -> ${res.status}`); + markEventStreamReady(); + debug('event stream connected'); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx = buffer.indexOf('\n\n'); + while (idx >= 0) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + handleSseBlock(block); + idx = buffer.indexOf('\n\n'); + } + } + debug('event stream closed; reconnecting'); + } catch (err) { + debug(`event stream error: ${err.message}; reconnecting`); + } + if (!shuttingDown) await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +function handleSseBlock(block) { + const dataLines = block + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trim()); + if (dataLines.length === 0) return; + let event; + try { + event = JSON.parse(dataLines.join('\n')); + } catch { + return; + } + if (event && typeof event.type === 'string') handleServeEvent(event); +} + +function eventSessionId(properties) { + return ( + properties?.sessionID || + properties?.part?.sessionID || + properties?.info?.sessionID || + properties?.permission?.sessionID || + null + ); +} + +function streamPartText(sessionId, part, delta) { + if (!part || (part.type !== 'text' && part.type !== 'reasoning')) return; + let chunk = ''; + if (typeof delta === 'string' && delta.length > 0) { + chunk = delta; + partTextSent.set(part.id, (partTextSent.get(part.id) || 0) + delta.length); + } else if (typeof part.text === 'string') { + const sent = partTextSent.get(part.id) || 0; + chunk = part.text.slice(sent); + partTextSent.set(part.id, part.text.length); + } + if (!chunk) return; + sessionUpdate(sessionId, { + sessionUpdate: part.type === 'reasoning' ? 'agent_thought_chunk' : 'agent_message_chunk', + content: { type: 'text', text: chunk }, + }); +} + +const PERMISSION_OPTION_TO_RESPONSE = { + allow_once: 'once', + allow_always: 'always', + reject_once: 'reject', + reject_always: 'reject', +}; + +async function handlePermissionUpdated(properties) { + const permission = properties?.permission ?? properties; + const sessionId = permission?.sessionID; + const permissionId = permission?.id; + if (!sessionId || !permissionId || !sessions.has(sessionId)) return; + const kindByType = { edit: 'edit', write: 'edit', bash: 'execute', external_directory: 'other' }; + let outcome; + try { + outcome = await requestClient('session/request_permission', { + sessionId, + toolCall: { + toolCallId: permissionId, + title: permission.title || `Permission requested: ${permission.type || 'tool'}`, + kind: kindByType[permission.type] || 'other', + status: 'pending', + rawInput: permission.metadata ?? {}, + }, + options: [ + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }, + { optionId: 'reject_always', name: 'Always reject', kind: 'reject_always' }, + ], + }); + } catch (err) { + debug(`request_permission failed (${err.message}); rejecting ${permissionId}`); + outcome = null; + } + let response = 'reject'; + if (outcome && outcome.outcome?.outcome === 'selected') { + response = PERMISSION_OPTION_TO_RESPONSE[outcome.outcome.optionId] || 'reject'; + } + const { directory } = sessions.get(sessionId) || {}; + try { + await serveJson( + withDirectory(`/session/${sessionId}/permissions/${permissionId}`, directory), + { method: 'POST', body: { response } }, + ); + } catch (err) { + debug(`posting permission response failed: ${err.message}`); + } +} + +function finishPrompt(sessionId, result, error) { + const pending = pendingPrompts.get(sessionId); + if (!pending) return; + pendingPrompts.delete(sessionId); + partTextSent.clear(); + if (error) sendError(pending.requestId, -32603, error); + else sendResult(pending.requestId, result); +} + +function handleServeEvent(event) { + const { type, properties } = event; + const sessionId = eventSessionId(properties || {}); + switch (type) { + case 'message.part.updated': + if (sessionId && pendingPrompts.has(sessionId)) { + streamPartText(sessionId, properties.part, properties.delta); + } + break; + case 'permission.updated': + void handlePermissionUpdated(properties || {}); + break; + case 'session.idle': + if (sessionId && pendingPrompts.has(sessionId)) { + const wasCancelled = cancelledSessions.has(sessionId); + cancelledSessions.delete(sessionId); + finishPrompt(sessionId, { stopReason: wasCancelled ? 'cancelled' : 'end_turn' }); + } + break; + case 'session.error': { + if (sessionId && pendingPrompts.has(sessionId)) { + const message = + properties?.error?.data?.message || properties?.error?.name || 'session error'; + finishPrompt(sessionId, null, `serve session error: ${message}`); + } + break; + } + default: + break; + } +} + +// --------------------------------------------------------------------------- +// ACP method handlers +// --------------------------------------------------------------------------- + +function acpBlocksToServeParts(blocks) { + const parts = []; + for (const block of blocks || []) { + if (!block || typeof block.type !== 'string') continue; + switch (block.type) { + case 'text': + parts.push({ type: 'text', text: block.text || '' }); + break; + case 'image': + parts.push({ + type: 'file', + mime: block.mimeType || 'image/png', + url: `data:${block.mimeType || 'image/png'};base64,${block.data || ''}`, + }); + break; + case 'resource': + if (typeof block.resource?.text === 'string') { + parts.push({ type: 'text', text: block.resource.text }); + } else if (typeof block.resource?.blob === 'string') { + const mime = block.resource.mimeType || 'application/octet-stream'; + parts.push({ type: 'file', mime, url: `data:${mime};base64,${block.resource.blob}` }); + } + break; + case 'resource_link': + parts.push({ type: 'file', mime: block.mimeType || '', url: block.uri }); + break; + default: + debug(`dropping unsupported ACP content block type: ${block.type}`); + break; + } + } + return parts; +} + +const handlers = { + initialize(params) { + const requested = params?.protocolVersion; + return { + protocolVersion: requested === PROTOCOL_VERSION ? requested : PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + mcpCapabilities: { http: false, sse: false }, + }, + agentInfo: { name: AGENT_NAME, title: 'OpenCode ACP-to-HTTP adapter (shared serve)', version: AGENT_VERSION }, + authMethods: [], + }; + }, + + async authenticate() { + return {}; + }, + + async 'session/new'(params) { + const cwd = params?.cwd || process.cwd(); + await ensureEventStream(); + const session = await serveJson(withDirectory('/session', cwd), { method: 'POST', body: {} }); + if (!session?.id) throw new Error('serve POST /session returned no session id'); + sessions.set(session.id, { directory: cwd }); + debug(`session/new -> ${session.id} (directory ${cwd})`); + return { sessionId: session.id }; + }, + + async 'session/prompt'(params, requestId) { + const sessionId = params?.sessionId; + if (!sessionId || !sessions.has(sessionId)) { + throw new Error(`unknown session: ${sessionId}`); + } + if (pendingPrompts.has(sessionId)) { + throw new Error(`session ${sessionId} already has a prompt in flight`); + } + const { directory } = sessions.get(sessionId); + const parts = acpBlocksToServeParts(params.prompt); + // Register before posting so fast events are not missed; the reply is + // sent by finishPrompt when the serve reports session.idle / error. + pendingPrompts.set(sessionId, { requestId }); + try { + await serveJson(withDirectory(`/session/${sessionId}/prompt_async`, directory), { + method: 'POST', + body: { parts }, + }); + } catch (err) { + pendingPrompts.delete(sessionId); + throw err; + } + return undefined; // response deferred to finishPrompt + }, + + async 'session/cancel'(params) { + const sessionId = params?.sessionId; + if (!sessionId || !sessions.has(sessionId)) return; + cancelledSessions.add(sessionId); + const { directory } = sessions.get(sessionId); + try { + await serveJson(withDirectory(`/session/${sessionId}/abort`, directory), { + method: 'POST', + body: {}, + }); + } catch (err) { + debug(`abort failed for ${sessionId}: ${err.message}`); + } + }, + + async 'session/set_mode'() { + return {}; + }, + + async 'session/set_model'() { + return {}; + }, +}; + +async function dispatch(message) { + const { id, method, params } = message; + const isRequest = id !== undefined && id !== null; + if (!method) { + // A response to one of our requests to the client. + if (isRequest && pendingClientRequests.has(id)) { + const pending = pendingClientRequests.get(id); + pendingClientRequests.delete(id); + if (message.error) pending.reject(new Error(message.error.message || 'client request failed')); + else pending.resolve(message.result); + } + return; + } + const handler = handlers[method]; + if (!handler) { + if (isRequest) sendError(id, -32601, `method not found: ${method}`); + return; + } + try { + const result = await handler(params || {}, id); + if (isRequest && method !== 'session/prompt') sendResult(id, result ?? {}); + else if (isRequest && result !== undefined) sendResult(id, result); + } catch (err) { + if (isRequest) sendError(id, -32603, err.message || String(err)); + } +} + +function main() { + void runEventStream(); + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let message; + try { + message = JSON.parse(trimmed); + } catch { + debug(`ignoring non-JSON line on stdin: ${trimmed.slice(0, 120)}`); + return; + } + void dispatch(message); + }); + rl.on('close', () => { + shuttingDown = true; + process.exit(0); + }); + process.on('SIGTERM', () => { + shuttingDown = true; + process.exit(0); + }); + debug(`started; server=${serverBase}`); +} + +main(); diff --git a/bin/opencode-acp-adapter.test.mjs b/bin/opencode-acp-adapter.test.mjs new file mode 100644 index 000000000..746572f56 --- /dev/null +++ b/bin/opencode-acp-adapter.test.mjs @@ -0,0 +1,262 @@ +// Tests for the OpenCode ACP-to-HTTP adapter (#1243). +// Run: node --test bin/opencode-acp-adapter.test.mjs +// +// A scripted ACP client (this file) drives the adapter over stdio +// against a stub OpenCode serve HTTP API, covering: initialize, +// session/new -> POST /session (cwd as directory), a streamed prompt +// round-trip (prompt_async + SSE -> session/update notifications), +// a permission round-trip (permission.updated -> +// session/request_permission -> POST permissions), and cancel +// (session/cancel -> POST abort, prompt resolves "cancelled"). +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const binDir = path.dirname(fileURLToPath(import.meta.url)); +const SESSION_ID = 'ses_test1243'; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Stub serve API. `script` runs when prompt_async arrives; it gets +// `emit(event)` to push SSE events to the adapter. +// --------------------------------------------------------------------------- +function startStubServe(script) { + const calls = []; + const sseClients = new Set(); + const emit = (event) => { + const payload = `data: ${JSON.stringify(event)}\n\n`; + for (const res of sseClients) res.write(payload); + }; + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + const body = raw ? JSON.parse(raw) : null; + calls.push({ method: req.method, pathname: url.pathname, query: url.searchParams, body }); + if (req.method === 'GET' && url.pathname === '/event') { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + res.flushHeaders(); + sseClients.add(res); + // NB: req 'close' fires as soon as the (bodyless) request is + // consumed, long before the stream ends — unregister on the + // response closing instead. + res.on('close', () => sseClients.delete(res)); + return; + } + if (req.method === 'POST' && url.pathname === '/session') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ id: SESSION_ID, directory: url.searchParams.get('directory') })); + return; + } + if (req.method === 'POST' && url.pathname === `/session/${SESSION_ID}/prompt_async`) { + res.writeHead(204); + res.end(); + setImmediate(() => script(emit, body)); + return; + } + if (req.method === 'POST' && /\/permissions\//.test(url.pathname)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('true'); + return; + } + if (req.method === 'POST' && url.pathname === `/session/${SESSION_ID}/abort`) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('true'); + emit({ type: 'session.idle', properties: { sessionID: SESSION_ID } }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ server, calls, port: server.address().port }); + }); + }); +} + +// --------------------------------------------------------------------------- +// Scripted ACP client over the adapter's stdio. +// --------------------------------------------------------------------------- +function startAdapter(serverPort) { + const child = spawn(process.execPath, [ + path.join(binDir, 'opencode-acp-adapter.mjs'), + '--server', + `http://127.0.0.1:${serverPort}`, + ]); + const pending = new Map(); + const notifications = []; + const clientRequests = []; + let buffer = ''; + let nextId = 1; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let idx = buffer.indexOf('\n'); + while (idx >= 0) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + idx = buffer.indexOf('\n'); + if (!line) continue; + const msg = JSON.parse(line); + if (msg.method && msg.id !== undefined) { + clientRequests.push(msg); + } else if (msg.method) { + notifications.push(msg); + } else if (msg.id !== undefined && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(msg.error.message)); + else resolve(msg.result); + } + } + }); + const request = (method, params) => { + const id = nextId; + nextId += 1; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + }); + }; + const notify = (method, params) => { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); + }; + const respond = (id, result) => { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); + }; + const waitFor = async (predicate, what, timeoutMs = 8000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const found = predicate(); + if (found) return found; + await sleep(25); + } + throw new Error(`timed out waiting for ${what}`); + }; + return { child, request, notify, respond, notifications, clientRequests, waitFor }; +} + +async function setup(t, script) { + const stub = await startStubServe(script); + const acp = startAdapter(stub.port); + t.after(() => { + acp.child.kill('SIGKILL'); + stub.server.close(); + }); + const init = await acp.request('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return { stub, acp, init }; +} + +const textEvents = () => (emit) => { + emit({ + type: 'message.part.updated', + properties: { + part: { id: 'part1', sessionID: SESSION_ID, type: 'text', text: 'Hello ' }, + delta: 'Hello ', + }, + }); + emit({ + type: 'message.part.updated', + properties: { + part: { id: 'part1', sessionID: SESSION_ID, type: 'text', text: 'Hello world' }, + delta: 'world', + }, + }); + emit({ type: 'session.idle', properties: { sessionID: SESSION_ID } }); +}; + +test('initialize negotiates ACP v1 and advertises prompt capabilities', async (t) => { + const { init } = await setup(t, textEvents()); + assert.equal(init.protocolVersion, 1); + assert.equal(init.agentCapabilities.loadSession, false); + assert.equal(init.agentCapabilities.promptCapabilities.image, true); + assert.equal(init.agentInfo.name, 'opencode-acp-adapter'); +}); + +test('session/new creates the session on the shared serve with cwd as directory', async (t) => { + const { stub, acp } = await setup(t, textEvents()); + const result = await acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + assert.equal(result.sessionId, SESSION_ID); + const create = stub.calls.find((c) => c.method === 'POST' && c.pathname === '/session'); + assert.ok(create, 'stub serve saw POST /session'); + assert.equal(create.query.get('directory'), '/home/user/code'); +}); + +test('session/prompt streams serve SSE parts as session/update and completes on idle', async (t) => { + const { stub, acp } = await setup(t, textEvents()); + await acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + const result = await acp.request('session/prompt', { + sessionId: SESSION_ID, + prompt: [{ type: 'text', text: 'say hello' }], + }); + assert.equal(result.stopReason, 'end_turn'); + const promptCall = stub.calls.find((c) => c.pathname.endsWith('/prompt_async')); + assert.ok(promptCall, 'stub serve saw prompt_async'); + assert.deepEqual(promptCall.body.parts, [{ type: 'text', text: 'say hello' }]); + const chunks = acp.notifications + .filter((n) => n.method === 'session/update' && n.params.update.sessionUpdate === 'agent_message_chunk') + .map((n) => n.params.update.content.text); + assert.equal(chunks.join(''), 'Hello world'); +}); + +test('serve permission events round-trip through session/request_permission', async (t) => { + const script = (emit) => { + emit({ + type: 'permission.updated', + properties: { id: 'perm1', sessionID: SESSION_ID, type: 'bash', title: 'Run ls', metadata: {} }, + }); + // Idle arrives only after the adapter posts the permission reply; + // the stub cannot see that from here, so idle on a delay and let + // the assertions below order-check via the recorded calls. + setTimeout(() => emit({ type: 'session.idle', properties: { sessionID: SESSION_ID } }), 500); + }; + const { stub, acp } = await setup(t, script); + await acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + const promptPromise = acp.request('session/prompt', { + sessionId: SESSION_ID, + prompt: [{ type: 'text', text: 'list files' }], + }); + const permRequest = await acp.waitFor( + () => acp.clientRequests.find((r) => r.method === 'session/request_permission'), + 'session/request_permission', + ); + assert.equal(permRequest.params.sessionId, SESSION_ID); + assert.equal(permRequest.params.toolCall.toolCallId, 'perm1'); + acp.respond(permRequest.id, { outcome: { outcome: 'selected', optionId: 'allow_once' } }); + const result = await promptPromise; + assert.equal(result.stopReason, 'end_turn'); + const permCall = stub.calls.find((c) => c.pathname === `/session/${SESSION_ID}/permissions/perm1`); + assert.ok(permCall, 'stub serve saw POST permissions/perm1'); + assert.deepEqual(permCall.body, { response: 'once' }); +}); + +test('session/cancel aborts on the serve and the prompt resolves cancelled', async (t) => { + const { stub, acp } = await setup(t, () => { + // Never idles on its own: only the abort (stub emits idle) ends it. + }); + await acp.request('session/new', { cwd: '/home/user/code', mcpServers: [] }); + const promptPromise = acp.request('session/prompt', { + sessionId: SESSION_ID, + prompt: [{ type: 'text', text: 'long task' }], + }); + await acp.waitFor( + () => stub.calls.find((c) => c.pathname.endsWith('/prompt_async')), + 'prompt_async to reach the stub', + ); + acp.notify('session/cancel', { sessionId: SESSION_ID }); + const result = await promptPromise; + assert.equal(result.stopReason, 'cancelled'); + assert.ok( + stub.calls.find((c) => c.pathname === `/session/${SESSION_ID}/abort`), + 'stub serve saw POST abort', + ); +}); diff --git a/home/openclaw/default.nix b/home/openclaw/default.nix index 060407f26..444299afa 100644 --- a/home/openclaw/default.nix +++ b/home/openclaw/default.nix @@ -42,13 +42,19 @@ in imports = [ inputs.nix-openclaw.homeManagerModules.openclaw ]; # Point acpx's harnesses at this laptop's pinned binaries instead of - # acpx's default `npx -y ...` upstream downloads: opencode is the pinned - # OpenCode fork (the build OpenChamber uses); claude/codex run the local - # wrappers in ~/.openclaw/acpx (bundled adapters, no npx fetch); agy is - # the system antigravity-acp server. + # acpx's default `npx -y ...` upstream downloads: opencode runs through + # the ACP-to-HTTP adapter in this repo (bin/opencode-acp-adapter.mjs, + # #1243) so OpenClaw sessions land on the standing shared + # `opencode serve` at 127.0.0.1:13420 instead of a private per-process + # server; claude/codex run the local wrappers in ~/.openclaw/acpx + # (bundled adapters, no npx fetch); agy is the system + # antigravity-acp server. + # FALLBACK (#1243): to revert opencode to the private-server path, + # restore opencode.command below (and agents.opencode in the embedded + # acpx override) to "/etc/profiles/per-user/user/bin/opencode acp". home.file.".acpx/config.json".text = builtins.toJSON { agents = { - opencode.command = "/etc/profiles/per-user/user/bin/opencode acp"; + opencode.command = "/home/user/code/bin/opencode-acp-adapter.mjs --server http://127.0.0.1:13420"; claude.command = "/home/user/.openclaw/acpx/claude-agent-acp-wrapper.mjs"; codex.command = "/home/user/.openclaw/acpx/codex-acp-wrapper.mjs"; agy.command = "/run/current-system/sw/bin/agy_acp_server"; @@ -395,9 +401,13 @@ in # (and the npx fetch hangs on this laptop's npmjs route). # The standalone acpx CLI reads ~/.acpx/config.json instead; # that file is kept in sync above, but the plugin ignores it. + # Via the ACP-to-HTTP adapter (#1243), not `opencode acp`: + # sessions land on the shared serve at 127.0.0.1:13420. + # Fallback: command "/etc/profiles/per-user/user/bin/opencode" + # with args [ "acp" ] restores the private-server path. agents.opencode = { - command = "/etc/profiles/per-user/user/bin/opencode"; - args = [ "acp" ]; + command = "${homeDir}/code/bin/opencode-acp-adapter.mjs"; + args = [ "--server" "http://127.0.0.1:13420" ]; }; agents.claude = { command = "/home/user/.openclaw/acpx/claude-agent-acp-wrapper.mjs";