From dd78b7f773fcd9477000e1eb4ae71cccc5846c00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 13:38:00 +0100 Subject: [PATCH] fix: harden OpenCode ACP bind dispatch --- CHANGELOG.md | 1 + docs/help/testing-live.md | 13 ++- docs/help/testing.md | 2 +- extensions/acpx/package.json | 2 +- package.json | 1 + pnpm-lock.yaml | 24 ++--- pnpm-workspace.yaml | 1 + scripts/test-docker-all.mjs | 15 ++++ scripts/test-live-acp-bind-docker.sh | 18 +++- src/auto-reply/reply/dispatch-acp-delivery.ts | 1 + .../reply/dispatch-acp-transcript.runtime.ts | 56 ++++++++++++ src/auto-reply/reply/dispatch-acp.test.ts | 34 ++++++- src/auto-reply/reply/dispatch-acp.ts | 32 +++++++ .../reply/dispatch-from-config.test.ts | 90 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 45 +++++++++- src/gateway/gateway-acp-bind.live.test.ts | 24 ++++- src/gateway/live-agent-probes.test.ts | 1 + src/gateway/live-agent-probes.ts | 5 +- test/scripts/test-projects.test.ts | 14 ++- 19 files changed, 348 insertions(+), 31 deletions(-) create mode 100644 src/auto-reply/reply/dispatch-acp-transcript.runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e884d87116..3bd149ea397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532. +- ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests. - Memory-host SDK: use trusted env-proxy mode for remote embedding and batch HTTP calls only when Undici will proxy that target, preserving SSRF DNS pinning for `ALL_PROXY`-only and `NO_PROXY` bypass cases. Fixes #52162. (#71506) Thanks @DhtIsCoding. - Gateway/dashboard: render Control UI and WebSocket links with `https://`/`wss://` when `gateway.tls.enabled=true`, including `openclaw gateway status`. Fixes #71494. (#71499) Thanks @deepkilo. - Agents/OpenAI-compatible: default proxy/local completions tool requests to `tool_choice: "auto"` when tools are present, so providers enter native tool-calling mode instead of replying with plain-text tool directives. (#71472) Thanks @Speed-maker. diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 30e098b3480..38d661bc731 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -208,9 +208,12 @@ Notes: - `OPENCLAW_LIVE_ACP_BIND_AGENT=claude` - `OPENCLAW_LIVE_ACP_BIND_AGENT=codex` - `OPENCLAW_LIVE_ACP_BIND_AGENT=gemini` + - `OPENCLAW_LIVE_ACP_BIND_AGENT=opencode` - `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini` - `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@'` - `OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL=gpt-5.2` + - `OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL=opencode/kimi-k2.6` + - `OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT=1` - `OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL=openai/gpt-5.2` - Notes: - This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally. @@ -236,15 +239,17 @@ Single-agent Docker recipes: pnpm test:docker:live-acp-bind:claude pnpm test:docker:live-acp-bind:codex pnpm test:docker:live-acp-bind:gemini +pnpm test:docker:live-acp-bind:opencode ``` Docker notes: - The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`. -- By default, it runs the ACP bind smoke against all supported live CLI agents in sequence: `claude`, `codex`, then `gemini`. -- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini` to narrow the matrix. -- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) if missing. -- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI. +- By default, it runs the ACP bind smoke against the aggregate live CLI agents in sequence: `claude`, `codex`, then `gemini`. +- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=opencode` to narrow the matrix. +- It sources `~/.profile`, stages the matching CLI auth material into the container, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, `@google/gemini-cli`, or `opencode-ai`) if missing. The ACP backend itself is the bundled embedded `acpx/runtime` package from the `acpx` plugin. +- The OpenCode Docker variant is a strict single-agent regression lane. It writes a temporary `OPENCODE_CONFIG_CONTENT` default model from `OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL` (default `opencode/kimi-k2.6`) after sourcing `~/.profile`, and `pnpm test:docker:live-acp-bind:opencode` requires a bound assistant transcript instead of accepting the generic post-bind skip. +- Direct `acpx` CLI calls are only a manual/workaround path for comparing behavior outside the Gateway. The Docker ACP bind smoke exercises OpenClaw's embedded `acpx` runtime backend. ## Live: Codex app-server harness smoke diff --git a/docs/help/testing.md b/docs/help/testing.md index e8e9b273189..0f075885975 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -588,7 +588,7 @@ These Docker runners split into two buckets: The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: - Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`) -- ACP bind smoke: `pnpm test:docker:live-acp-bind` (script: `scripts/test-live-acp-bind-docker.sh`) +- ACP bind smoke: `pnpm test:docker:live-acp-bind` (script: `scripts/test-live-acp-bind-docker.sh`; covers Claude, Codex, and Gemini by default, with strict OpenCode coverage via `pnpm test:docker:live-acp-bind:opencode`) - CLI backend smoke: `pnpm test:docker:live-cli-backend` (script: `scripts/test-live-cli-backend-docker.sh`) - Codex app-server harness smoke: `pnpm test:docker:live-codex-harness` (script: `scripts/test-live-codex-harness-docker.sh`) - Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`) diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index a6b7152a6fe..a23f2a7ff75 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -4,7 +4,7 @@ "description": "OpenClaw ACP runtime backend", "type": "module", "dependencies": { - "acpx": "0.5.3" + "acpx": "0.6.0" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/package.json b/package.json index 384df656fbf..7355ed59f0c 100644 --- a/package.json +++ b/package.json @@ -1489,6 +1489,7 @@ "test:docker:live-acp-bind:claude": "OPENCLAW_LIVE_ACP_BIND_AGENT=claude bash scripts/test-live-acp-bind-docker.sh", "test:docker:live-acp-bind:codex": "OPENCLAW_LIVE_ACP_BIND_AGENT=codex bash scripts/test-live-acp-bind-docker.sh", "test:docker:live-acp-bind:gemini": "OPENCLAW_LIVE_ACP_BIND_AGENT=gemini bash scripts/test-live-acp-bind-docker.sh", + "test:docker:live-acp-bind:opencode": "OPENCLAW_LIVE_ACP_BIND_AGENT=opencode OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT=1 bash scripts/test-live-acp-bind-docker.sh", "test:docker:live-build": "bash scripts/test-live-build-docker.sh", "test:docker:live-cli-backend": "bash scripts/test-live-cli-backend-docker.sh", "test:docker:live-cli-backend:claude": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6 bash scripts/test-live-cli-backend-docker.sh", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ad3d60568a..5ebedc4822b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,8 +215,8 @@ importers: extensions/acpx: dependencies: acpx: - specifier: 0.5.3 - version: 0.5.3 + specifier: 0.6.0 + version: 0.6.0 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1519,13 +1519,13 @@ importers: packages: - '@agentclientprotocol/sdk@0.17.1': - resolution: {integrity: sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==} + '@agentclientprotocol/sdk@0.19.1': + resolution: {integrity: sha512-oSb3RzjlMoU3Xu6MRJAL/Gd1DyK2+XSmZyUENrt/j1yqt33+ROhxncU6em8nyXEs97D4lVIGaFZ1pN0Q1C9SpA==} peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@agentclientprotocol/sdk@0.19.1': - resolution: {integrity: sha512-oSb3RzjlMoU3Xu6MRJAL/Gd1DyK2+XSmZyUENrt/j1yqt33+ROhxncU6em8nyXEs97D4lVIGaFZ1pN0Q1C9SpA==} + '@agentclientprotocol/sdk@0.20.0': + resolution: {integrity: sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -4323,8 +4323,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acpx@0.5.3: - resolution: {integrity: sha512-LNKc9gWlRztWKtQ3jr4g/kzlL9HU/5Wor79mromg/zRV5vE2FOdU+8VtW8ZypIMLzxLx2ATN6A4S1Dr97DM2QQ==} + acpx@0.6.0: + resolution: {integrity: sha512-ThJ2NLLc3kos3MzC8yrPrJeIfpRuwp2+/aMRkfKfJ0cATSbkV25NEi93d0Vx9f2NIpSEhr+mGQpbvphBrrSRPA==} engines: {node: '>=22.12.0'} hasBin: true @@ -7557,11 +7557,11 @@ packages: snapshots: - '@agentclientprotocol/sdk@0.17.1(zod@4.3.6)': + '@agentclientprotocol/sdk@0.19.1(zod@4.3.6)': dependencies: zod: 4.3.6 - '@agentclientprotocol/sdk@0.19.1(zod@4.3.6)': + '@agentclientprotocol/sdk@0.20.0(zod@4.3.6)': dependencies: zod: 4.3.6 @@ -11054,9 +11054,9 @@ snapshots: acorn@8.16.0: {} - acpx@0.5.3: + acpx@0.6.0: dependencies: - '@agentclientprotocol/sdk': 0.17.1(zod@4.3.6) + '@agentclientprotocol/sdk': 0.20.0(zod@4.3.6) commander: 14.0.3 skillflag: 0.1.4 tsx: 4.21.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b96de6dce25..3f8393aca30 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ minimumReleaseAge: 2880 minimumReleaseAgeExclude: - "acpx" + - "@agentclientprotocol/sdk" - "axios" - "basic-ftp" - "hono" diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 4621b604de1..a904073fcc3 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -26,6 +26,7 @@ const DEFAULT_RESOURCE_LIMITS = { "live:claude": 4, "live:codex": 4, "live:gemini": 4, + "live:opencode": 4, npm: 10, service: 7, }; @@ -69,6 +70,9 @@ function liveProviderResource(provider) { if (provider === "google-gemini-cli" || provider === "gemini") { return "live:gemini"; } + if (provider === "opencode") { + return "live:opencode"; + } if (provider === "openai") { return "live:openai"; } @@ -321,6 +325,17 @@ const exclusiveLanes = [ weight: 3, }, ), + liveLane( + "live-acp-bind-opencode", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-acp-bind:opencode", + { + cacheKey: "acp-bind-opencode", + provider: "opencode", + resources: ["npm"], + timeoutMs: LIVE_ACP_TIMEOUT_MS, + weight: 3, + }, + ), ]; const tailLanes = exclusiveLanes; diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index 3124101c153..f8b42eccd97 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -31,8 +31,9 @@ openclaw_live_acp_bind_resolve_auth_provider() { claude) printf '%s\n' "claude-cli" ;; codex) printf '%s\n' "codex-cli" ;; gemini) printf '%s\n' "google-gemini-cli" ;; + opencode) printf '%s\n' "opencode" ;; *) - echo "Unsupported OPENCLAW_LIVE_ACP_BIND agent: ${1:-} (expected claude, codex, or gemini)" >&2 + echo "Unsupported OPENCLAW_LIVE_ACP_BIND agent: ${1:-} (expected claude, codex, gemini, or opencode)" >&2 return 1 ;; esac @@ -43,6 +44,7 @@ openclaw_live_acp_bind_resolve_agent_command() { claude) printf '%s' "${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND_CLAUDE:-${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}}" ;; codex) printf '%s' "${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND_CODEX:-${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}}" ;; gemini) printf '%s' "${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND_GEMINI:-${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}}" ;; + opencode) printf '%s' "${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND_OPENCODE:-${OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND:-}}" ;; *) return 1 ;; esac } @@ -157,6 +159,14 @@ WRAP npm install -g @google/gemini-cli fi ;; + opencode) + if [ ! -x "$NPM_CONFIG_PREFIX/bin/opencode" ]; then + npm install -g opencode-ai + fi + export OPENCODE_CONFIG_CONTENT="$( + node -e 'process.stdout.write(JSON.stringify({model: process.env.OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL || "opencode/kimi-k2.6"}))' + )" + ;; *) echo "Unsupported OPENCLAW_LIVE_ACP_BIND_AGENT: $agent" >&2 exit 1 @@ -187,7 +197,7 @@ for token in "${ACP_AGENT_TOKENS[@]}"; do done if ((${#ACP_AGENTS[@]} == 0)); then - echo "No ACP bind agents selected. Use OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini." >&2 + echo "No ACP bind agents selected. Use OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini,opencode." >&2 exit 1 fi @@ -274,6 +284,9 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e GEMINI_API_KEY \ -e GOOGLE_API_KEY \ -e OPENAI_API_KEY \ + -e OPENCODE_API_KEY \ + -e OPENCODE_ZEN_API_KEY \ + -e OPENCODE_CONFIG_CONTENT \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ @@ -286,6 +299,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_LIVE_ACP_BIND=1 \ -e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \ + -e OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL="${OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL:-opencode/kimi-k2.6}" \ -e OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="$AGENT_COMMAND") openclaw_live_append_array DOCKER_RUN_ARGS DOCKER_HOME_MOUNT DOCKER_RUN_ARGS+=(\ diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index ea9f7cb40ab..c1a16816404 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -332,6 +332,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { requesterSenderE164: params.ctx.SenderE164, threadId: params.ctx.MessageThreadId, cfg: params.cfg, + mirror: false, }); if (!result.ok) { if (tracksVisibleText) { diff --git a/src/auto-reply/reply/dispatch-acp-transcript.runtime.ts b/src/auto-reply/reply/dispatch-acp-transcript.runtime.ts new file mode 100644 index 00000000000..1c517475f76 --- /dev/null +++ b/src/auto-reply/reply/dispatch-acp-transcript.runtime.ts @@ -0,0 +1,56 @@ +import { resolveAcpSessionCwd } from "../../acp/runtime/session-identifiers.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { persistAcpTurnTranscript } from "../../agents/command/attempt-execution.js"; +import { + loadSessionStore, + resolveSessionStoreEntry, + resolveStorePath, +} from "../../config/sessions.js"; +import type { SessionAcpMeta } from "../../config/sessions/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +export async function persistAcpDispatchTranscript(params: { + cfg: OpenClawConfig; + sessionKey: string; + promptText: string; + finalText: string; + meta?: SessionAcpMeta; + threadId?: string | number; +}): Promise { + const promptText = params.promptText.trim(); + const finalText = params.finalText.trim(); + if (!promptText && !finalText) { + return; + } + + const sessionAgentId = resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: params.cfg, + }); + const storePath = resolveStorePath(params.cfg.session?.store, { + agentId: sessionAgentId, + }); + const sessionStore = loadSessionStore(storePath, { skipCache: true }); + const sessionEntry = resolveSessionStoreEntry({ + store: sessionStore, + sessionKey: params.sessionKey, + }).existing; + const sessionId = sessionEntry?.sessionId; + if (!sessionId) { + throw new Error(`unknown ACP session key: ${params.sessionKey}`); + } + + await persistAcpTurnTranscript({ + body: promptText, + transcriptBody: promptText, + finalText, + sessionId, + sessionKey: params.sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId, + threadId: params.threadId, + sessionCwd: resolveAcpSessionCwd(params.meta) ?? process.cwd(), + }); +} diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 4c4db38f595..6632375df84 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -34,7 +34,9 @@ const policyMocks = vi.hoisted(() => ({ })); const routeMocks = vi.hoisted(() => ({ - routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })), + routeReply: vi.fn< + (_params: unknown) => Promise<{ ok: true; messageId: string } | { ok: false; error: string }> + >(async () => ({ ok: true, messageId: "mock" })), })); const channelPluginMocks = vi.hoisted(() => ({ @@ -78,6 +80,10 @@ const sessionMetaMocks = vi.hoisted(() => ({ >(() => null), })); +const transcriptMocks = vi.hoisted(() => ({ + persistAcpDispatchTranscript: vi.fn(async (_params: unknown) => undefined), +})); + const bindingServiceMocks = vi.hoisted(() => ({ listBySession: vi.fn<(sessionKey: string) => SessionBindingRecord[]>(() => []), unbind: vi.fn<(input: unknown) => Promise>(async () => []), @@ -162,6 +168,11 @@ vi.mock("./dispatch-acp-session.runtime.js", () => ({ sessionMetaMocks.readAcpSessionEntry(params), })); +vi.mock("./dispatch-acp-transcript.runtime.js", () => ({ + persistAcpDispatchTranscript: (params: unknown) => + transcriptMocks.persistAcpDispatchTranscript(params), +})); + const sessionKey = "agent:codex-acp:session-1"; const originalFetch = globalThis.fetch; type MockTtsReply = Awaited>; @@ -359,6 +370,7 @@ describe("tryDispatchAcpReply", () => { mediaUnderstandingMocks.applyMediaUnderstanding.mockResolvedValue(undefined); sessionMetaMocks.readAcpSessionEntry.mockReset(); sessionMetaMocks.readAcpSessionEntry.mockReturnValue(null); + transcriptMocks.persistAcpDispatchTranscript.mockClear(); bindingServiceMocks.listBySession.mockReset(); bindingServiceMocks.listBySession.mockReturnValue([]); bindingServiceMocks.unbind.mockReset(); @@ -387,6 +399,26 @@ describe("tryDispatchAcpReply", () => { expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); }); + it("persists ACP transcript when routed delivery fails", async () => { + setReadyAcpResolution(); + mockRoutedTextTurn("hello"); + routeMocks.routeReply.mockResolvedValue({ ok: false, error: "missing channel adapter" }); + + await runDispatch({ + bodyForAgent: "reply", + shouldRouteToOriginating: true, + }); + + expect(transcriptMocks.persistAcpDispatchTranscript).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey, + promptText: "reply", + finalText: "hello", + }), + ); + expect(routeMocks.routeReply).toHaveBeenCalledWith(expect.objectContaining({ mirror: false })); + }); + it("edits ACP tool lifecycle updates in place when supported", async () => { setReadyAcpResolution(); mockToolLifecycleTurn("call-1"); diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index b7dafe94a1f..aedc38720b4 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -43,6 +43,9 @@ let dispatchAcpSessionRuntimePromise: Promise< > | null = null; let dispatchAcpTtsRuntimePromise: Promise | null = null; +let dispatchAcpTranscriptRuntimePromise: Promise< + typeof import("./dispatch-acp-transcript.runtime.js") +> | null = null; function loadDispatchAcpManagerRuntime() { dispatchAcpManagerRuntimePromise ??= import("./dispatch-acp-manager.runtime.js"); @@ -59,6 +62,11 @@ function loadDispatchAcpTtsRuntime() { return dispatchAcpTtsRuntimePromise; } +function loadDispatchAcpTranscriptRuntime() { + dispatchAcpTranscriptRuntimePromise ??= import("./dispatch-acp-transcript.runtime.js"); + return dispatchAcpTranscriptRuntimePromise; +} + type DispatchProcessedRecorder = ( outcome: "completed" | "skipped" | "error", opts?: { @@ -440,6 +448,30 @@ export async function tryDispatchAcpReply(params: { }); await projector.flush(true); + if (params.abortSignal?.aborted) { + const counts = params.dispatcher.getQueuedCounts(); + delivery.applyRoutedCounts(counts); + params.recordProcessed("completed", { reason: "acp_aborted" }); + params.markIdle("message_aborted"); + return { queuedFinal, counts }; + } + try { + const { persistAcpDispatchTranscript } = await loadDispatchAcpTranscriptRuntime(); + await persistAcpDispatchTranscript({ + cfg: params.cfg, + sessionKey: canonicalSessionKey, + promptText, + finalText: delivery.getAccumulatedBlockText(), + meta: acpResolution.meta, + threadId: params.ctx.MessageThreadId, + }); + } catch (error) { + logVerbose( + `dispatch-acp: transcript persistence failed for ${canonicalSessionKey}: ${formatErrorMessage( + error, + )}`, + ); + } queuedFinal = (await finalizeAcpTurnOutput({ cfg: params.cfg, diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 5a9ef665e09..f00e197d3db 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -2153,6 +2153,96 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalled(); }); + it("retargets reply_dispatch to a bound generic ACP session before model fallback", async () => { + setNoAbort(); + const boundSessionKey = "agent:opencode:acp:bound-session"; + const runtime = createAcpRuntime([ + { type: "text_delta", text: "Bound ACP reply" }, + { type: "done" }, + ]); + acpMocks.readAcpSessionEntry.mockImplementation( + (params: { sessionKey: string; cfg?: OpenClawConfig }) => + params.sessionKey === boundSessionKey + ? { + sessionKey: boundSessionKey, + storeSessionKey: boundSessionKey, + cfg: {}, + storePath: "/tmp/mock-sessions.json", + entry: {}, + acp: { + backend: "acpx", + agent: "opencode", + runtimeSessionName: "runtime:opencode", + mode: "persistent", + state: "idle", + lastActivityAt: Date.now(), + }, + } + : null, + ); + acpMocks.requireAcpRuntimeBackend.mockReturnValue({ + id: "acpx", + runtime, + }); + sessionBindingMocks.resolveByConversation.mockReturnValue({ + bindingId: "binding-acp-current", + targetSessionKey: boundSessionKey, + targetKind: "session", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "C123", + }, + status: "active", + boundAt: Date.now(), + } satisfies SessionBindingRecord); + + const cfg = { + acp: { + enabled: true, + dispatch: { enabled: true }, + stream: { coalesceIdleMs: 0, maxChunkChars: 256 }, + }, + } as OpenClawConfig; + const dispatcher = createDispatcher(); + const replyResolver = vi.fn(async () => ({ text: "fallback reply" }) satisfies ReplyPayload); + const ctx = buildTestCtx({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "slack", + OriginatingTo: "slack:C123", + To: "slack:C123", + AccountId: "default", + SessionKey: "agent:main:slack:C123", + BodyForAgent: "continue", + }); + + const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(result.queuedFinal).toBe(true); + expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({ + channel: "slack", + accountId: "default", + conversationId: "C123", + }); + expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-acp-current"); + expect(runtime.ensureSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: boundSessionKey, + agent: "opencode", + }), + ); + expect(runtime.runTurn).toHaveBeenCalledWith( + expect.objectContaining({ + text: "continue", + }), + ); + expect(replyResolver).not.toHaveBeenCalled(); + expect(dispatcher.sendBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "Bound ACP reply" }), + ); + }); + it("coalesces tiny ACP token deltas into normal Discord text spacing", async () => { setNoAbort(); const runtime = createAcpRuntime([ diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 01d3ab2bd28..9957f6e1f8a 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -26,6 +26,7 @@ import { } from "../../hooks/message-hook-mappers.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; import { logMessageProcessed, logMessageQueued, @@ -41,9 +42,10 @@ import { toPluginConversationBinding, } from "../../plugins/conversation-binding.js"; import { getGlobalHookRunner, getGlobalPluginRegistry } from "../../plugins/hook-runner-global.js"; +import { isAcpSessionKey } from "../../routing/session-key.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { + normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; @@ -57,6 +59,7 @@ import type { BlockReplyContext } from "../get-reply-options.types.js"; import { getReplyPayloadMetadata, type ReplyPayload } from "../reply-payload.js"; import type { FinalizedMsgContext } from "../templating.js"; import { normalizeVerboseLevel } from "../thinking.js"; +import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js"; import { createInternalHookEvent, loadSessionStore, @@ -211,6 +214,37 @@ const resolveSessionStoreLookup = ( } }; +const resolveBoundAcpDispatchSessionKey = (params: { + ctx: FinalizedMsgContext; + cfg: OpenClawConfig; +}): string | undefined => { + const bindingContext = resolveConversationBindingContextFromMessage({ + cfg: params.cfg, + ctx: params.ctx, + }); + if (!bindingContext) { + return undefined; + } + + const binding = getSessionBindingService().resolveByConversation({ + channel: bindingContext.channel, + accountId: bindingContext.accountId, + conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), + }); + const targetSessionKey = normalizeOptionalString(binding?.targetSessionKey); + if (!binding || !targetSessionKey || !isAcpSessionKey(targetSessionKey)) { + return undefined; + } + if (isPluginOwnedSessionBindingRecord(binding)) { + return undefined; + } + getSessionBindingService().touch(binding.bindingId); + return targetSessionKey; +}; + const createShouldEmitVerboseProgress = (params: { sessionKey?: string; storePath?: string; @@ -300,8 +334,13 @@ export async function dispatchReplyFromConfig( return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } - const sessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); - const acpDispatchSessionKey = sessionStoreEntry.sessionKey ?? sessionKey; + const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); + const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg }); + const acpDispatchSessionKey = + boundAcpDispatchSessionKey ?? initialSessionStoreEntry.sessionKey ?? sessionKey; + const sessionStoreEntry = boundAcpDispatchSessionKey + ? resolveSessionStoreLookup({ ...ctx, SessionKey: boundAcpDispatchSessionKey }, cfg) + : initialSessionStoreEntry; const sessionAgentId = resolveSessionAgentId({ sessionKey: acpDispatchSessionKey, config: cfg }); const sessionAgentCfg = resolveAgentConfig(cfg, sessionAgentId); const shouldEmitVerboseProgress = createShouldEmitVerboseProgress({ diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 9efd325262c..3baa4104ca9 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -38,7 +38,7 @@ const CONNECT_TIMEOUT_MS = 90_000; const LIVE_TIMEOUT_MS = 240_000; const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5"; const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4"; -type LiveAcpAgent = "claude" | "codex" | "gemini"; +type LiveAcpAgent = "claude" | "codex" | "gemini" | "opencode"; function createSlackCurrentConversationBindingRegistry() { return createTestRegistry([ @@ -76,6 +76,9 @@ function normalizeAcpAgent(raw: string | undefined): LiveAcpAgent { if (normalized === "codex") { return "codex"; } + if (normalized === "opencode") { + return "opencode"; + } return "claude"; } @@ -136,6 +139,13 @@ function logLiveStep(message: string): void { console.info(`[live-acp-bind] ${message}`); } +function shouldRequireBoundAssistantTranscript(liveAgent: LiveAcpAgent): boolean { + return ( + liveAgent === "opencode" || + isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT) + ); +} + function normalizeOpenAiModelRef(value: string): string { const trimmed = value.trim(); if (!trimmed) { @@ -632,6 +642,11 @@ describeLive("gateway live (ACP bind)", () => { }); } catch { if (attempt === 2) { + if (shouldRequireBoundAssistantTranscript(liveAgent)) { + throw new Error( + `${liveAgent} ACP bind completed, but the bound session did not emit an assistant transcript`, + ); + } console.error( `SKIP: ${liveAgent} ACP bind completed, but the bound session did not emit an assistant transcript; skipping post-bind live probes.`, ); @@ -760,6 +775,11 @@ describeLive("gateway live (ACP bind)", () => { }); } catch { if (attempt === 2) { + if (shouldRequireBoundAssistantTranscript(liveAgent)) { + throw new Error( + `${liveAgent} ACP bind completed, but the bound session did not emit the marker transcript`, + ); + } console.error( `SKIP: ${liveAgent} ACP bind completed, but the bound session did not emit the marker transcript; skipping remaining post-bind live probes.`, ); @@ -913,7 +933,7 @@ describeLive("gateway live (ACP bind)", () => { clearRuntimeConfigSnapshot(); await client.stopAndWait({ timeoutMs: 2_000 }).catch(() => {}); await server.close(); - await fs.rm(tempRoot, { recursive: true, force: true }); + await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); if (previous.configPath === undefined) { delete process.env.OPENCLAW_CONFIG_PATH; } else { diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index ef39ee0f834..fc2eff6e00e 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -12,6 +12,7 @@ describe("live-agent-probes", () => { expect(normalizeLiveAgentFamily("claude-cli")).toBe("claude"); expect(normalizeLiveAgentFamily("codex")).toBe("codex"); expect(normalizeLiveAgentFamily("google-gemini-cli")).toBe("gemini"); + expect(normalizeLiveAgentFamily("opencode-ai")).toBe("opencode"); }); it("accepts only cat for the shared image probe reply", () => { diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index c85a63c03bf..5e39d3f1534 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -5,7 +5,7 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; const execFileAsync = promisify(execFile); -export type LiveAgentFamily = "claude" | "codex" | "gemini"; +export type LiveAgentFamily = "claude" | "codex" | "gemini" | "opencode"; export type CronListCliResult = { jobs?: Array<{ @@ -39,6 +39,9 @@ export function normalizeLiveAgentFamily(raw: string): LiveAgentFamily { if (normalized === "gemini" || normalized === "google-gemini-cli") { return "gemini"; } + if (normalized === "opencode" || normalized === "opencode-ai") { + return "opencode"; + } throw new Error(`unsupported live agent family: ${raw}`); } diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 827f46e333b..609ae435004 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -497,6 +497,12 @@ describe("scripts/test-projects changed-target routing", () => { }); describe("scripts/test-projects local heavy-check lock", () => { + const localCheckEnv = () => ({ + ...process.env, + OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: undefined, + OPENCLAW_TEST_PROJECTS_FORCE_LOCK: undefined, + }); + it("skips the lock for a single scoped tooling run", () => { expect( shouldAcquireLocalHeavyCheckLock( @@ -507,7 +513,7 @@ describe("scripts/test-projects local heavy-check lock", () => { watchMode: false, }, ], - process.env, + localCheckEnv(), ), ).toBe(false); }); @@ -522,7 +528,7 @@ describe("scripts/test-projects local heavy-check lock", () => { watchMode: false, }, ], - process.env, + localCheckEnv(), ), ).toBe(true); }); @@ -538,7 +544,7 @@ describe("scripts/test-projects local heavy-check lock", () => { }, ], { - ...process.env, + ...localCheckEnv(), OPENCLAW_TEST_HEAVY_CHECK_LOCK_HELD: "1", }, ), @@ -556,7 +562,7 @@ describe("scripts/test-projects local heavy-check lock", () => { }, ], { - ...process.env, + ...localCheckEnv(), OPENCLAW_TEST_PROJECTS_FORCE_LOCK: "1", }, ),