mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
fix: harden OpenCode ACP bind dispatch
This commit is contained in:
parent
42514156e0
commit
dd78b7f773
19 changed files with 348 additions and 31 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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@<version>'`
|
||||
- `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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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:*"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ minimumReleaseAge: 2880
|
|||
|
||||
minimumReleaseAgeExclude:
|
||||
- "acpx"
|
||||
- "@agentclientprotocol/sdk"
|
||||
- "axios"
|
||||
- "basic-ftp"
|
||||
- "hono"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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+=(\
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
56
src/auto-reply/reply/dispatch-acp-transcript.runtime.ts
Normal file
56
src/auto-reply/reply/dispatch-acp-transcript.runtime.ts
Normal file
|
|
@ -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<void> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -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<SessionBindingRecord[]>>(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<ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>>;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ let dispatchAcpSessionRuntimePromise: Promise<
|
|||
> | null = null;
|
||||
let dispatchAcpTtsRuntimePromise: Promise<typeof import("./dispatch-acp-tts.runtime.js")> | 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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue