feat: ACP thread-bound agents (#23580)

* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
Onur Solmaz
2026-02-26 11:00:09 +01:00
committed by GitHub
parent a9d9a968ed
commit a7d56e3554
151 changed files with 19005 additions and 324 deletions

4
.github/labeler.yml vendored
View File

@@ -240,6 +240,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/device-pair/**"
"extensions: acpx":
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: minimax-portal-auth":
- changed-files:
- any-glob-to-any-file:

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with `acp` spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
- Android/Nodes: add `notifications.list` support on Android nodes and expose `nodes notifications_list` in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
- Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional `configureInteractive` and `configureWhenConfigured` hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.

View File

@@ -1002,7 +1002,12 @@
},
{
"group": "Agent coordination",
"pages": ["tools/agent-send", "tools/subagents", "tools/multi-agent-sandbox-tools"]
"pages": [
"tools/agent-send",
"tools/subagents",
"tools/acp-agents",
"tools/multi-agent-sandbox-tools"
]
},
{
"group": "Skills",

View File

@@ -0,0 +1,800 @@
---
summary: "Integrate ACP coding agents via a first-class ACP control plane in core and plugin-backed runtimes (acpx first)"
owner: "onutc"
status: "draft"
last_updated: "2026-02-25"
title: "ACP Thread Bound Agents"
---
# ACP Thread Bound Agents
## Overview
This plan defines how OpenClaw should support ACP coding agents in thread-capable channels (Discord first) with production-level lifecycle and recovery.
Related document:
- [Unified Runtime Streaming Refactor Plan](/experiments/plans/acp-unified-streaming-refactor)
Target user experience:
- a user spawns or focuses an ACP session into a thread
- user messages in that thread route to the bound ACP session
- agent output streams back to the same thread persona
- session can be persistent or one shot with explicit cleanup controls
## Decision summary
Long term recommendation is a hybrid architecture:
- OpenClaw core owns ACP control plane concerns
- session identity and metadata
- thread binding and routing decisions
- delivery invariants and duplicate suppression
- lifecycle cleanup and recovery semantics
- ACP runtime backend is pluggable
- first backend is an acpx-backed plugin service
- runtime does ACP transport, queueing, cancel, reconnect
OpenClaw should not reimplement ACP transport internals in core.
OpenClaw should not rely on a pure plugin-only interception path for routing.
## North-star architecture (holy grail)
Treat ACP as a first-class control plane in OpenClaw, with pluggable runtime adapters.
Non-negotiable invariants:
- every ACP thread binding references a valid ACP session record
- every ACP session has explicit lifecycle state (`creating`, `idle`, `running`, `cancelling`, `closed`, `error`)
- every ACP run has explicit run state (`queued`, `running`, `completed`, `failed`, `cancelled`)
- spawn, bind, and initial enqueue are atomic
- command retries are idempotent (no duplicate runs or duplicate Discord outputs)
- bound-thread channel output is a projection of ACP run events, never ad-hoc side effects
Long-term ownership model:
- `AcpSessionManager` is the single ACP writer and orchestrator
- manager lives in gateway process first; can be moved to a dedicated sidecar later behind the same interface
- per ACP session key, manager owns one in-memory actor (serialized command execution)
- adapters (`acpx`, future backends) are transport/runtime implementations only
Long-term persistence model:
- move ACP control-plane state to a dedicated SQLite store (WAL mode) under OpenClaw state dir
- keep `SessionEntry.acp` as compatibility projection during migration, not source-of-truth
- store ACP events append-only to support replay, crash recovery, and deterministic delivery
### Delivery strategy (bridge to holy-grail)
- short-term bridge
- keep current thread binding mechanics and existing ACP config surface
- fix metadata-gap bugs and route ACP turns through a single core ACP branch
- add idempotency keys and fail-closed routing checks immediately
- long-term cutover
- move ACP source-of-truth to control-plane DB + actors
- make bound-thread delivery purely event-projection based
- remove legacy fallback behavior that depends on opportunistic session-entry metadata
## Why not pure plugin only
Current plugin hooks are not sufficient for end to end ACP session routing without core changes.
- inbound routing from thread binding resolves to a session key in core dispatch first
- message hooks are fire-and-forget and cannot short-circuit the main reply path
- plugin commands are good for control operations but not for replacing core per-turn dispatch flow
Result:
- ACP runtime can be pluginized
- ACP routing branch must exist in core
## Existing foundation to reuse
Already implemented and should remain canonical:
- thread binding target supports `subagent` and `acp`
- inbound thread routing override resolves by binding before normal dispatch
- outbound thread identity via webhook in reply delivery
- `/focus` and `/unfocus` flow with ACP target compatibility
- persistent binding store with restore on startup
- unbind lifecycle on archive, delete, unfocus, reset, and delete
This plan extends that foundation rather than replacing it.
## Architecture
### Boundary model
Core (must be in OpenClaw core):
- ACP session-mode dispatch branch in the reply pipeline
- delivery arbitration to avoid parent plus thread duplication
- ACP control-plane persistence (with `SessionEntry.acp` compatibility projection during migration)
- lifecycle unbind and runtime detach semantics tied to session reset/delete
Plugin backend (acpx implementation):
- ACP runtime worker supervision
- acpx process invocation and event parsing
- ACP command handlers (`/acp ...`) and operator UX
- backend-specific config defaults and diagnostics
### Runtime ownership model
- one gateway process owns ACP orchestration state
- ACP execution runs in supervised child processes via acpx backend
- process strategy is long lived per active ACP session key, not per message
This avoids startup cost on every prompt and keeps cancel and reconnect semantics reliable.
### Core runtime contract
Add a core ACP runtime contract so routing code does not depend on CLI details and can switch backends without changing dispatch logic:
```ts
export type AcpRuntimePromptMode = "prompt" | "steer";
export type AcpRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
};
export type AcpRuntimeEvent =
| { type: "text_delta"; stream: "output" | "thought"; text: string }
| { type: "tool_call"; name: string; argumentsText: string }
| { type: "done"; usage?: Record<string, number> }
| { type: "error"; code: string; message: string; retryable?: boolean };
export interface AcpRuntime {
ensureSession(input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
env?: Record<string, string>;
idempotencyKey: string;
}): Promise<AcpRuntimeHandle>;
submit(input: {
handle: AcpRuntimeHandle;
text: string;
mode: AcpRuntimePromptMode;
idempotencyKey: string;
}): Promise<{ runtimeRunId: string }>;
stream(input: {
handle: AcpRuntimeHandle;
runtimeRunId: string;
onEvent: (event: AcpRuntimeEvent) => Promise<void> | void;
signal?: AbortSignal;
}): Promise<void>;
cancel(input: {
handle: AcpRuntimeHandle;
runtimeRunId?: string;
reason?: string;
idempotencyKey: string;
}): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise<void>;
health?(): Promise<{ ok: boolean; details?: string }>;
}
```
Implementation detail:
- first backend: `AcpxRuntime` shipped as a plugin service
- core resolves runtime via registry and fails with explicit operator error when no ACP runtime backend is available
### Control-plane data model and persistence
Long-term source-of-truth is a dedicated ACP SQLite database (WAL mode), for transactional updates and crash-safe recovery:
- `acp_sessions`
- `session_key` (pk), `backend`, `agent`, `mode`, `cwd`, `state`, `created_at`, `updated_at`, `last_error`
- `acp_runs`
- `run_id` (pk), `session_key` (fk), `state`, `requester_message_id`, `idempotency_key`, `started_at`, `ended_at`, `error_code`, `error_message`
- `acp_bindings`
- `binding_key` (pk), `thread_id`, `channel_id`, `account_id`, `session_key` (fk), `expires_at`, `bound_at`
- `acp_events`
- `event_id` (pk), `run_id` (fk), `seq`, `kind`, `payload_json`, `created_at`
- `acp_delivery_checkpoint`
- `run_id` (pk/fk), `last_event_seq`, `last_discord_message_id`, `updated_at`
- `acp_idempotency`
- `scope`, `idempotency_key`, `result_json`, `created_at`, unique `(scope, idempotency_key)`
```ts
export type AcpSessionMeta = {
backend: string;
agent: string;
runtimeSessionName: string;
mode: "persistent" | "oneshot";
cwd?: string;
state: "idle" | "running" | "error";
lastActivityAt: number;
lastError?: string;
};
```
Storage rules:
- keep `SessionEntry.acp` as a compatibility projection during migration
- process ids and sockets stay in memory only
- durable lifecycle and run status live in ACP DB, not generic session JSON
- if runtime owner dies, gateway rehydrates from ACP DB and resumes from checkpoints
### Routing and delivery
Inbound:
- keep current thread binding lookup as first routing step
- if bound target is ACP session, route to ACP runtime branch instead of `getReplyFromConfig`
- explicit `/acp steer` command uses `mode: "steer"`
Outbound:
- ACP event stream is normalized to OpenClaw reply chunks
- delivery target is resolved through existing bound destination path
- when a bound thread is active for that session turn, parent channel completion is suppressed
Streaming policy:
- stream partial output with coalescing window
- configurable min interval and max chunk bytes to stay under Discord rate limits
- final message always emitted on completion or failure
### State machines and transaction boundaries
Session state machine:
- `creating -> idle -> running -> idle`
- `running -> cancelling -> idle | error`
- `idle -> closed`
- `error -> idle | closed`
Run state machine:
- `queued -> running -> completed`
- `running -> failed | cancelled`
- `queued -> cancelled`
Required transaction boundaries:
- spawn transaction
- create ACP session row
- create/update ACP thread binding row
- enqueue initial run row
- close transaction
- mark session closed
- delete/expire binding rows
- write final close event
- cancel transaction
- mark target run cancelling/cancelled with idempotency key
No partial success is allowed across these boundaries.
### Per-session actor model
`AcpSessionManager` runs one actor per ACP session key:
- actor mailbox serializes `submit`, `cancel`, `close`, and `stream` side effects
- actor owns runtime handle hydration and runtime adapter process lifecycle for that session
- actor writes run events in-order (`seq`) before any Discord delivery
- actor updates delivery checkpoints after successful outbound send
This removes cross-turn races and prevents duplicate or out-of-order thread output.
### Idempotency and delivery projection
All external ACP actions must carry idempotency keys:
- spawn idempotency key
- prompt/steer idempotency key
- cancel idempotency key
- close idempotency key
Delivery rules:
- Discord messages are derived from `acp_events` plus `acp_delivery_checkpoint`
- retries resume from checkpoint without re-sending already delivered chunks
- final reply emission is exactly-once per run from projection logic
### Recovery and self-healing
On gateway start:
- load non-terminal ACP sessions (`creating`, `idle`, `running`, `cancelling`, `error`)
- recreate actors lazily on first inbound event or eagerly under configured cap
- reconcile any `running` runs missing heartbeats and mark `failed` or recover via adapter
On inbound Discord thread message:
- if binding exists but ACP session is missing, fail closed with explicit stale-binding message
- optionally auto-unbind stale binding after operator-safe validation
- never silently route stale ACP bindings to normal LLM path
### Lifecycle and safety
Supported operations:
- cancel current run: `/acp cancel`
- unbind thread: `/unfocus`
- close ACP session: `/acp close`
- auto close idle sessions by effective TTL
TTL policy:
- effective TTL is minimum of
- global/session TTL
- Discord thread binding TTL
- ACP runtime owner TTL
Safety controls:
- allowlist ACP agents by name
- restrict workspace roots for ACP sessions
- env allowlist passthrough
- max concurrent ACP sessions per account and globally
- bounded restart backoff for runtime crashes
## Config surface
Core keys:
- `acp.enabled`
- `acp.dispatch.enabled` (independent ACP routing kill switch)
- `acp.backend` (default `acpx`)
- `acp.defaultAgent`
- `acp.allowedAgents[]`
- `acp.maxConcurrentSessions`
- `acp.stream.coalesceIdleMs`
- `acp.stream.maxChunkChars`
- `acp.runtime.ttlMinutes`
- `acp.controlPlane.store` (`sqlite` default)
- `acp.controlPlane.storePath`
- `acp.controlPlane.recovery.eagerActors`
- `acp.controlPlane.recovery.reconcileRunningAfterMs`
- `acp.controlPlane.checkpoint.flushEveryEvents`
- `acp.controlPlane.checkpoint.flushEveryMs`
- `acp.idempotency.ttlHours`
- `channels.discord.threadBindings.spawnAcpSessions`
Plugin/backend keys (acpx plugin section):
- backend command/path overrides
- backend env allowlist
- backend per-agent presets
- backend startup/stop timeouts
- backend max inflight runs per session
## Implementation specification
### Control-plane modules (new)
Add dedicated ACP control-plane modules in core:
- `src/acp/control-plane/manager.ts`
- owns ACP actors, lifecycle transitions, command serialization
- `src/acp/control-plane/store.ts`
- SQLite schema management, transactions, query helpers
- `src/acp/control-plane/events.ts`
- typed ACP event definitions and serialization
- `src/acp/control-plane/checkpoint.ts`
- durable delivery checkpoints and replay cursors
- `src/acp/control-plane/idempotency.ts`
- idempotency key reservation and response replay
- `src/acp/control-plane/recovery.ts`
- boot-time reconciliation and actor rehydrate plan
Compatibility bridge modules:
- `src/acp/runtime/session-meta.ts`
- remains temporarily for projection into `SessionEntry.acp`
- must stop being source-of-truth after migration cutover
### Required invariants (must enforce in code)
- ACP session creation and thread bind are atomic (single transaction)
- there is at most one active run per ACP session actor at a time
- event `seq` is strictly increasing per run
- delivery checkpoint never advances past last committed event
- idempotency replay returns previous success payload for duplicate command keys
- stale/missing ACP metadata cannot route into normal non-ACP reply path
### Core touchpoints
Core files to change:
- `src/auto-reply/reply/dispatch-from-config.ts`
- ACP branch calls `AcpSessionManager.submit` and event-projection delivery
- remove direct ACP fallback that bypasses control-plane invariants
- `src/auto-reply/reply/inbound-context.ts` (or nearest normalized context boundary)
- expose normalized routing keys and idempotency seeds for ACP control plane
- `src/config/sessions/types.ts`
- keep `SessionEntry.acp` as projection-only compatibility field
- `src/gateway/server-methods/sessions.ts`
- reset/delete/archive must call ACP manager close/unbind transaction path
- `src/infra/outbound/bound-delivery-router.ts`
- enforce fail-closed destination behavior for ACP bound session turns
- `src/discord/monitor/thread-bindings.ts`
- add ACP stale-binding validation helpers wired to control-plane lookups
- `src/auto-reply/reply/commands-acp.ts`
- route spawn/cancel/close/steer through ACP manager APIs
- `src/agents/acp-spawn.ts`
- stop ad-hoc metadata writes; call ACP manager spawn transaction
- `src/plugin-sdk/**` and plugin runtime bridge
- expose ACP backend registration and health semantics cleanly
Core files explicitly not replaced:
- `src/discord/monitor/message-handler.preflight.ts`
- keep thread binding override behavior as the canonical session-key resolver
### ACP runtime registry API
Add a core registry module:
- `src/acp/runtime/registry.ts`
Required API:
```ts
export type AcpRuntimeBackend = {
id: string;
runtime: AcpRuntime;
healthy?: () => boolean;
};
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void;
export function unregisterAcpRuntimeBackend(id: string): void;
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null;
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend;
```
Behavior:
- `requireAcpRuntimeBackend` throws a typed ACP backend missing error when unavailable
- plugin service registers backend on `start` and unregisters on `stop`
- runtime lookups are read-only and process-local
### acpx runtime plugin contract (implementation detail)
For the first production backend (`extensions/acpx`), OpenClaw and acpx are
connected with a strict command contract:
- backend id: `acpx`
- plugin service id: `acpx-runtime`
- runtime handle encoding: `runtimeSessionName = acpx:v1:<base64url(json)>`
- encoded payload fields:
- `name` (acpx named session; uses OpenClaw `sessionKey`)
- `agent` (acpx agent command)
- `cwd` (session workspace root)
- `mode` (`persistent | oneshot`)
Command mapping:
- ensure session:
- `acpx --format json --json-strict --cwd <cwd> <agent> sessions ensure --name <name>`
- prompt turn:
- `acpx --format json --json-strict --cwd <cwd> <agent> prompt --session <name> --file -`
- cancel:
- `acpx --format json --json-strict --cwd <cwd> <agent> cancel --session <name>`
- close:
- `acpx --format json --json-strict --cwd <cwd> <agent> sessions close <name>`
Streaming:
- OpenClaw consumes ndjson events from `acpx --format json --json-strict`
- `text` => `text_delta/output`
- `thought` => `text_delta/thought`
- `tool_call` => `tool_call`
- `done` => `done`
- `error` => `error`
### Session schema patch
Patch `SessionEntry` in `src/config/sessions/types.ts`:
```ts
type SessionAcpMeta = {
backend: string;
agent: string;
runtimeSessionName: string;
mode: "persistent" | "oneshot";
cwd?: string;
state: "idle" | "running" | "error";
lastActivityAt: number;
lastError?: string;
};
```
Persisted field:
- `SessionEntry.acp?: SessionAcpMeta`
Migration rules:
- phase A: dual-write (`acp` projection + ACP SQLite source-of-truth)
- phase B: read-primary from ACP SQLite, fallback-read from legacy `SessionEntry.acp`
- phase C: migration command backfills missing ACP rows from valid legacy entries
- phase D: remove fallback-read and keep projection optional for UX only
- legacy fields (`cliSessionIds`, `claudeCliSessionId`) remain untouched
### Error contract
Add stable ACP error codes and user-facing messages:
- `ACP_BACKEND_MISSING`
- message: `ACP runtime backend is not configured. Install and enable the acpx runtime plugin.`
- `ACP_BACKEND_UNAVAILABLE`
- message: `ACP runtime backend is currently unavailable. Try again in a moment.`
- `ACP_SESSION_INIT_FAILED`
- message: `Could not initialize ACP session runtime.`
- `ACP_TURN_FAILED`
- message: `ACP turn failed before completion.`
Rules:
- return actionable user-safe message in-thread
- log detailed backend/system error only in runtime logs
- never silently fall back to normal LLM path when ACP routing was explicitly selected
### Duplicate delivery arbitration
Single routing rule for ACP bound turns:
- if an active thread binding exists for the target ACP session and requester context, deliver only to that bound thread
- do not also send to parent channel for the same turn
- if bound destination selection is ambiguous, fail closed with explicit error (no implicit parent fallback)
- if no active binding exists, use normal session destination behavior
### Observability and operational readiness
Required metrics:
- ACP spawn success/failure count by backend and error code
- ACP run latency percentiles (queue wait, runtime turn time, delivery projection time)
- ACP actor restart count and restart reason
- stale-binding detection count
- idempotency replay hit rate
- Discord delivery retry and rate-limit counters
Required logs:
- structured logs keyed by `sessionKey`, `runId`, `backend`, `threadId`, `idempotencyKey`
- explicit state transition logs for session and run state machines
- adapter command logs with redaction-safe arguments and exit summary
Required diagnostics:
- `/acp sessions` includes state, active run, last error, and binding status
- `/acp doctor` (or equivalent) validates backend registration, store health, and stale bindings
### Config precedence and effective values
ACP enablement precedence:
- account override: `channels.discord.accounts.<id>.threadBindings.spawnAcpSessions`
- channel override: `channels.discord.threadBindings.spawnAcpSessions`
- global ACP gate: `acp.enabled`
- dispatch gate: `acp.dispatch.enabled`
- backend availability: registered backend for `acp.backend`
Auto-enable behavior:
- when ACP is configured (`acp.enabled=true`, `acp.dispatch.enabled=true`, or
`acp.backend=acpx`), plugin auto-enable marks `plugins.entries.acpx.enabled=true`
unless denylisted or explicitly disabled
TTL effective value:
- `min(session ttl, discord thread binding ttl, acp runtime ttl)`
### Test map
Unit tests:
- `src/acp/runtime/registry.test.ts` (new)
- `src/auto-reply/reply/dispatch-from-config.acp.test.ts` (new)
- `src/infra/outbound/bound-delivery-router.test.ts` (extend ACP fail-closed cases)
- `src/config/sessions/types.test.ts` or nearest session-store tests (ACP metadata persistence)
Integration tests:
- `src/discord/monitor/reply-delivery.test.ts` (bound ACP delivery target behavior)
- `src/discord/monitor/message-handler.preflight*.test.ts` (bound ACP session-key routing continuity)
- acpx plugin runtime tests in backend package (service register/start/stop + event normalization)
Gateway e2e tests:
- `src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts` (extend ACP reset/delete lifecycle coverage)
- ACP thread turn roundtrip e2e for spawn, message, stream, cancel, unfocus, restart recovery
### Rollout guard
Add independent ACP dispatch kill switch:
- `acp.dispatch.enabled` default `false` for first release
- when disabled:
- ACP spawn/focus control commands may still bind sessions
- ACP dispatch path does not activate
- user receives explicit message that ACP dispatch is disabled by policy
- after canary validation, default can be flipped to `true` in a later release
## Command and UX plan
### New commands
- `/acp spawn <agent-id> [--mode persistent|oneshot] [--thread auto|here|off]`
- `/acp cancel [session]`
- `/acp steer <instruction>`
- `/acp close [session]`
- `/acp sessions`
### Existing command compatibility
- `/focus <sessionKey>` continues to support ACP targets
- `/unfocus` keeps current semantics
- `/session ttl` remains the top level TTL override
## Phased rollout
### Phase 0 ADR and schema freeze
- ship ADR for ACP control-plane ownership and adapter boundaries
- freeze DB schema (`acp_sessions`, `acp_runs`, `acp_bindings`, `acp_events`, `acp_delivery_checkpoint`, `acp_idempotency`)
- define stable ACP error codes, event contract, and state-transition guards
### Phase 1 Control-plane foundation in core
- implement `AcpSessionManager` and per-session actor runtime
- implement ACP SQLite store and transaction helpers
- implement idempotency store and replay helpers
- implement event append + delivery checkpoint modules
- wire spawn/cancel/close APIs to manager with transactional guarantees
### Phase 2 Core routing and lifecycle integration
- route thread-bound ACP turns from dispatch pipeline into ACP manager
- enforce fail-closed routing when ACP binding/session invariants fail
- integrate reset/delete/archive/unfocus lifecycle with ACP close/unbind transactions
- add stale-binding detection and optional auto-unbind policy
### Phase 3 acpx backend adapter/plugin
- implement `acpx` adapter against runtime contract (`ensureSession`, `submit`, `stream`, `cancel`, `close`)
- add backend health checks and startup/teardown registration
- normalize acpx ndjson events into ACP runtime events
- enforce backend timeouts, process supervision, and restart/backoff policy
### Phase 4 Delivery projection and channel UX (Discord first)
- implement event-driven channel projection with checkpoint resume (Discord first)
- coalesce streaming chunks with rate-limit aware flush policy
- guarantee exactly-once final completion message per run
- ship `/acp spawn`, `/acp cancel`, `/acp steer`, `/acp close`, `/acp sessions`
### Phase 5 Migration and cutover
- introduce dual-write to `SessionEntry.acp` projection plus ACP SQLite source-of-truth
- add migration utility for legacy ACP metadata rows
- flip read path to ACP SQLite primary
- remove legacy fallback routing that depends on missing `SessionEntry.acp`
### Phase 6 Hardening, SLOs, and scale limits
- enforce concurrency limits (global/account/session), queue policies, and timeout budgets
- add full telemetry, dashboards, and alert thresholds
- chaos-test crash recovery and duplicate-delivery suppression
- publish runbook for backend outage, DB corruption, and stale-binding remediation
### Full implementation checklist
- core control-plane modules and tests
- DB migrations and rollback plan
- ACP manager API integration across dispatch and commands
- adapter registration interface in plugin runtime bridge
- acpx adapter implementation and tests
- thread-capable channel delivery projection logic with checkpoint replay (Discord first)
- lifecycle hooks for reset/delete/archive/unfocus
- stale-binding detector and operator-facing diagnostics
- config validation and precedence tests for all new ACP keys
- operational docs and troubleshooting runbook
## Test plan
Unit tests:
- ACP DB transaction boundaries (spawn/bind/enqueue atomicity, cancel, close)
- ACP state-machine transition guards for sessions and runs
- idempotency reservation/replay semantics across all ACP commands
- per-session actor serialization and queue ordering
- acpx event parser and chunk coalescer
- runtime supervisor restart and backoff policy
- config precedence and effective TTL calculation
- core ACP routing branch selection and fail-closed behavior when backend/session is invalid
Integration tests:
- fake ACP adapter process for deterministic streaming and cancel behavior
- ACP manager + dispatch integration with transactional persistence
- thread-bound inbound routing to ACP session key
- thread-bound outbound delivery suppresses parent channel duplication
- checkpoint replay recovers after delivery failure and resumes from last event
- plugin service registration and teardown of ACP runtime backend
Gateway e2e tests:
- spawn ACP with thread, exchange multi-turn prompts, unfocus
- gateway restart with persisted ACP DB and bindings, then continue same session
- concurrent ACP sessions in multiple threads have no cross-talk
- duplicate command retries (same idempotency key) do not create duplicate runs or replies
- stale-binding scenario yields explicit error and optional auto-clean behavior
## Risks and mitigations
- Duplicate deliveries during transition
- Mitigation: single destination resolver and idempotent event checkpoint
- Runtime process churn under load
- Mitigation: long lived per session owners + concurrency caps + backoff
- Plugin absent or misconfigured
- Mitigation: explicit operator-facing error and fail-closed ACP routing (no implicit fallback to normal session path)
- Config confusion between subagent and ACP gates
- Mitigation: explicit ACP keys and command feedback that includes effective policy source
- Control-plane store corruption or migration bugs
- Mitigation: WAL mode, backup/restore hooks, migration smoke tests, and read-only fallback diagnostics
- Actor deadlocks or mailbox starvation
- Mitigation: watchdog timers, actor health probes, and bounded mailbox depth with rejection telemetry
## Acceptance checklist
- ACP session spawn can create or bind a thread in a supported channel adapter (currently Discord)
- all thread messages route to bound ACP session only
- ACP outputs appear in the same thread identity with streaming or batches
- no duplicate output in parent channel for bound turns
- spawn+bind+initial enqueue are atomic in persistent store
- ACP command retries are idempotent and do not duplicate runs or outputs
- cancel, close, unfocus, archive, reset, and delete perform deterministic cleanup
- crash restart preserves mapping and resumes multi turn continuity
- concurrent thread bound ACP sessions work independently
- ACP backend missing state produces clear actionable error
- stale bindings are detected and surfaced explicitly (with optional safe auto-clean)
- control-plane metrics and diagnostics are available for operators
- new unit, integration, and e2e coverage passes
## Addendum: targeted refactors for current implementation (status)
These are non-blocking follow-ups to keep the ACP path maintainable after the current feature set lands.
### 1) Centralize ACP dispatch policy evaluation (completed)
- implemented via shared ACP policy helpers in `src/acp/policy.ts`
- dispatch, ACP command lifecycle handlers, and ACP spawn path now consume shared policy logic
### 2) Split ACP command handler by subcommand domain (completed)
- `src/auto-reply/reply/commands-acp.ts` is now a thin router
- subcommand behavior is split into:
- `src/auto-reply/reply/commands-acp/lifecycle.ts`
- `src/auto-reply/reply/commands-acp/runtime-options.ts`
- `src/auto-reply/reply/commands-acp/diagnostics.ts`
- shared helpers in `src/auto-reply/reply/commands-acp/shared.ts`
### 3) Split ACP session manager by responsibility (completed)
- manager is split into:
- `src/acp/control-plane/manager.ts` (public facade + singleton)
- `src/acp/control-plane/manager.core.ts` (manager implementation)
- `src/acp/control-plane/manager.types.ts` (manager types/deps)
- `src/acp/control-plane/manager.utils.ts` (normalization + helper functions)
### 4) Optional acpx runtime adapter cleanup
- `extensions/acpx/src/runtime.ts` can be split into:
- process execution/supervision
- ndjson event parsing/normalization
- runtime API surface (`submit`, `cancel`, `close`, etc.)
- improves testability and makes backend behavior easier to audit

View File

@@ -0,0 +1,96 @@
---
summary: "Holy grail refactor plan for one unified runtime streaming pipeline across main, subagent, and ACP"
owner: "onutc"
status: "draft"
last_updated: "2026-02-25"
title: "Unified Runtime Streaming Refactor Plan"
---
# Unified Runtime Streaming Refactor Plan
## Objective
Deliver one shared streaming pipeline for `main`, `subagent`, and `acp` so all runtimes get identical coalescing, chunking, delivery ordering, and crash recovery behavior.
## Why this exists
- Current behavior is split across multiple runtime-specific shaping paths.
- Formatting/coalescing bugs can be fixed in one path but remain in others.
- Delivery consistency, duplicate suppression, and recovery semantics are harder to reason about.
## Target architecture
Single pipeline, runtime-specific adapters:
1. Runtime adapters emit canonical events only.
2. Shared stream assembler coalesces and finalizes text/tool/status events.
3. Shared channel projector applies channel-specific chunking/formatting once.
4. Shared delivery ledger enforces idempotent send/replay semantics.
5. Outbound channel adapter executes sends and records delivery checkpoints.
Canonical event contract:
- `turn_started`
- `text_delta`
- `block_final`
- `tool_started`
- `tool_finished`
- `status`
- `turn_completed`
- `turn_failed`
- `turn_cancelled`
## Workstreams
### 1) Canonical streaming contract
- Define strict event schema + validation in core.
- Add adapter contract tests to guarantee each runtime emits compatible events.
- Reject malformed runtime events early and surface structured diagnostics.
### 2) Shared stream processor
- Replace runtime-specific coalescer/projector logic with one processor.
- Processor owns text delta buffering, idle flush, max-chunk splitting, and completion flush.
- Move ACP/main/subagent config resolution into one helper to prevent drift.
### 3) Shared channel projection
- Keep channel adapters dumb: accept finalized blocks and send.
- Move Discord-specific chunking quirks to channel projector only.
- Keep pipeline channel-agnostic before projection.
### 4) Delivery ledger + replay
- Add per-turn/per-chunk delivery IDs.
- Record checkpoints before and after physical send.
- On restart, replay pending chunks idempotently and avoid duplicates.
### 5) Migration and cutover
- Phase 1: shadow mode (new pipeline computes output but old path sends; compare).
- Phase 2: runtime-by-runtime cutover (`acp`, then `subagent`, then `main` or reverse by risk).
- Phase 3: delete legacy runtime-specific streaming code.
## Non-goals
- No changes to ACP policy/permissions model in this refactor.
- No channel-specific feature expansion outside projection compatibility fixes.
- No transport/backend redesign (acpx plugin contract remains as-is unless needed for event parity).
## Risks and mitigations
- Risk: behavioral regressions in existing main/subagent paths.
Mitigation: shadow mode diffing + adapter contract tests + channel e2e tests.
- Risk: duplicate sends during crash recovery.
Mitigation: durable delivery IDs + idempotent replay in delivery adapter.
- Risk: runtime adapters diverge again.
Mitigation: required shared contract test suite for all adapters.
## Acceptance criteria
- All runtimes pass shared streaming contract tests.
- Discord ACP/main/subagent produce equivalent spacing/chunking behavior for tiny deltas.
- Crash/restart replay sends no duplicate chunk for the same delivery ID.
- Legacy ACP projector/coalescer path is removed.
- Streaming config resolution is shared and runtime-independent.

View File

@@ -336,6 +336,11 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con
- Gateway networking (two containers, WS auth + health): `pnpm test:docker:gateway-network` (script: `scripts/e2e/gateway-network-docker.sh`)
- Plugins (custom extension load + registry smoke): `pnpm test:docker:plugins` (script: `scripts/e2e/plugins-docker.sh`)
Manual ACP plain-language thread smoke (not CI):
- `bun scripts/dev/discord-acp-plain-language-smoke.ts --channel <discord-channel-id> ...`
- Keep this script for regression/debug workflows. It may be needed again for ACP thread routing validation, so do not delete it.
Useful env vars:
- `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw`

265
docs/tools/acp-agents.md Normal file
View File

@@ -0,0 +1,265 @@
---
summary: "Use ACP runtime sessions for Pi, Claude Code, Codex, OpenCode, Gemini CLI, and other harness agents"
read_when:
- Running coding harnesses through ACP
- Setting up thread-bound ACP sessions on thread-capable channels
- Troubleshooting ACP backend and plugin wiring
title: "ACP Agents"
---
# ACP agents
ACP sessions let OpenClaw run external coding harnesses (for example Pi, Claude Code, Codex, OpenCode, and Gemini CLI) through an ACP backend plugin.
If you ask OpenClaw in plain language to "run this in Codex" or "start Claude Code in a thread", OpenClaw should route that request to the ACP runtime (not the native sub-agent runtime).
## Quick start for humans
Examples of natural requests:
- "Start a persistent Codex session in a thread here and keep it focused."
- "Run this as a one-shot Claude Code ACP session and summarize the result."
- "Use Gemini CLI for this task in a thread, then keep follow-ups in that same thread."
What OpenClaw should do:
1. Pick `runtime: "acp"`.
2. Resolve the requested harness target (`agentId`, for example `codex`).
3. If thread binding is requested and the current channel supports it, bind the ACP session to the thread.
4. Route follow-up thread messages to that same ACP session until unfocused/closed/expired.
## ACP versus sub-agents
Use ACP when you want an external harness runtime. Use sub-agents when you want OpenClaw-native delegated runs.
| Area | ACP session | Sub-agent run |
| ------------- | ------------------------------------- | ---------------------------------- |
| Runtime | ACP backend plugin (for example acpx) | OpenClaw native sub-agent runtime |
| Session key | `agent:<agentId>:acp:<uuid>` | `agent:<agentId>:subagent:<uuid>` |
| Main commands | `/acp ...` | `/subagents ...` |
| Spawn tool | `sessions_spawn` with `runtime:"acp"` | `sessions_spawn` (default runtime) |
See also [Sub-agents](/tools/subagents).
## Thread-bound sessions (channel-agnostic)
When thread bindings are enabled for a channel adapter, ACP sessions can be bound to threads:
- OpenClaw binds a thread to a target ACP session.
- Follow-up messages in that thread route to the bound ACP session.
- ACP output is delivered back to the same thread.
- Unfocus/close/archive/TTL expiry removes the binding.
Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear unsupported/unavailable message.
Required feature flags for thread-bound ACP:
- `acp.enabled=true`
- `acp.dispatch.enabled=true`
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
### Thread supporting channels
- Any channel adapter that exposes session/thread binding capability.
- Current built-in support: Discord.
- Plugin channels can add support through the same binding interface.
## Start ACP sessions (interfaces)
### From `sessions_spawn`
Use `runtime: "acp"` to start an ACP session from an agent turn or tool call.
```json
{
"task": "Open the repo and summarize failing tests",
"runtime": "acp",
"agentId": "codex",
"thread": true,
"mode": "session"
}
```
Notes:
- `runtime` defaults to `subagent`, so set `runtime: "acp"` explicitly for ACP sessions.
- If `agentId` is omitted, OpenClaw uses `acp.defaultAgent` when configured.
- `mode: "session"` requires `thread: true` to keep a persistent bound conversation.
Interface details:
- `task` (required): initial prompt sent to the ACP session.
- `runtime` (required for ACP): must be `"acp"`.
- `agentId` (optional): ACP target harness id. Falls back to `acp.defaultAgent` if set.
- `thread` (optional, default `false`): request thread binding flow where supported.
- `mode` (optional): `run` (one-shot) or `session` (persistent).
- default is `run`
- if `thread: true` and mode omitted, OpenClaw may default to persistent behavior per runtime path
- `mode: "session"` requires `thread: true`
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
- `label` (optional): operator-facing label used in session/banner text.
### From `/acp` command
Use `/acp spawn` for explicit operator control from chat when needed.
```text
/acp spawn codex --mode persistent --thread auto
/acp spawn codex --mode oneshot --thread off
/acp spawn codex --thread here
```
Key flags:
- `--mode persistent|oneshot`
- `--thread auto|here|off`
- `--cwd <absolute-path>`
- `--label <name>`
See [Slash Commands](/tools/slash-commands).
## ACP controls
Available command family:
- `/acp spawn`
- `/acp cancel`
- `/acp steer`
- `/acp close`
- `/acp status`
- `/acp set-mode`
- `/acp set`
- `/acp cwd`
- `/acp permissions`
- `/acp timeout`
- `/acp model`
- `/acp reset-options`
- `/acp sessions`
- `/acp doctor`
- `/acp install`
`/acp status` shows the effective runtime options and, when available, both runtime-level and backend-level session identifiers.
Some controls depend on backend capabilities. If a backend does not support a control, OpenClaw returns a clear unsupported-control error.
## acpx harness support (current)
Current acpx built-in harness aliases:
- `pi`
- `claude`
- `codex`
- `opencode`
- `gemini`
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
Direct acpx CLI usage can also target arbitrary adapters via `--agent <command>`, but that raw escape hatch is an acpx CLI feature (not the normal OpenClaw `agentId` path).
## Required config
Core ACP baseline:
```json5
{
acp: {
enabled: true,
dispatch: { enabled: true },
backend: "acpx",
defaultAgent: "codex",
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
maxConcurrentSessions: 8,
stream: {
coalesceIdleMs: 300,
maxChunkChars: 1200,
},
runtime: {
ttlMinutes: 120,
},
},
}
```
Thread binding config is channel-adapter specific. Example for Discord:
```json5
{
session: {
threadBindings: {
enabled: true,
ttlHours: 24,
},
},
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
}
```
If thread-bound ACP spawn does not work, verify the adapter feature flag first:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
See [Configuration Reference](/gateway/configuration-reference).
## Plugin setup for acpx backend
Install and enable plugin:
```bash
openclaw plugins install @openclaw/acpx
openclaw config set plugins.entries.acpx.enabled true
```
Local workspace install during development:
```bash
openclaw plugins install ./extensions/acpx
```
Then verify backend health:
```text
/acp doctor
```
### Pinned acpx install strategy (current behavior)
`@openclaw/acpx` now enforces a strict plugin-local pinning model:
1. The extension pins an exact acpx dependency in `extensions/acpx/package.json`.
2. Runtime command is fixed to the plugin-local binary (`extensions/acpx/node_modules/.bin/acpx`), not global `PATH`.
3. Plugin config does not expose `command` or `commandArgs`, so runtime command drift is blocked.
4. Startup registers the ACP backend immediately as not-ready.
5. A background ensure job verifies `acpx --version` against the pinned version.
6. If missing/mismatched, it runs plugin-local install (`npm install --omit=dev --no-save acpx@<pinned>`) and re-verifies before healthy.
Notes:
- OpenClaw startup stays non-blocking while acpx ensure runs.
- If network/install fails, backend remains unavailable and `/acp doctor` reports an actionable fix.
See [Plugins](/tools/plugin).
## Troubleshooting
- Error: `ACP runtime backend is not configured`
Install and enable the configured backend plugin, then run `/acp doctor`.
- Error: ACP dispatch disabled
Enable `acp.dispatch.enabled=true`.
- Error: target agent not allowed
Pass an allowed `agentId` or update `acp.allowedAgents`.
- Error: thread binding unavailable on this channel
Use a channel adapter that supports thread bindings, or run ACP in non-thread mode.
- Error: missing ACP metadata for a bound session
Recreate the session with `/acp spawn` (or `sessions_spawn` with `runtime:"acp"`) and rebind the thread.

View File

@@ -464,7 +464,7 @@ Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `thinking?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
Notes:
@@ -474,6 +474,7 @@ Notes:
- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
- If `thread: true` and `mode` is omitted, mode defaults to `session`.

View File

@@ -80,6 +80,7 @@ Text + native (when enabled):
- `/whoami` (show your sender id; alias: `/id`)
- `/session ttl <duration|off>` (manage session-level settings, such as TTL)
- `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session)
- `/acp spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions` (inspect and control ACP runtime sessions)
- `/agents` (list thread-bound agents for this session)
- `/focus <target>` (Discord: bind this thread, or a new thread, to a session/subagent target)
- `/unfocus` (Discord: remove the current thread binding)
@@ -125,6 +126,7 @@ Notes:
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
- ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.

View File

@@ -51,6 +51,7 @@ These commands work on channels that support persistent thread bindings. See **T
- `--model` and `--thinking` override defaults for that specific run.
- Use `info`/`log` to inspect details and output after completion.
- `/subagents spawn` is one-shot mode (`mode: "run"`). For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`.
- For ACP harness sessions (Codex, Claude Code, Gemini CLI), use `sessions_spawn` with `runtime: "acp"` and see [ACP Agents](/tools/acp-agents).
Primary goals:

19
extensions/acpx/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { createAcpxPluginConfigSchema } from "./src/config.js";
import { createAcpxRuntimeService } from "./src/service.js";
const plugin = {
id: "acpx",
name: "ACPX Runtime",
description: "ACP runtime backend powered by the acpx CLI.",
configSchema: createAcpxPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(
createAcpxRuntimeService({
pluginConfig: api.pluginConfig,
}),
);
},
};
export default plugin;

View File

@@ -0,0 +1,55 @@
{
"id": "acpx",
"name": "ACPX Runtime",
"description": "ACP runtime backend powered by a pinned plugin-local acpx CLI.",
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"cwd": {
"type": "string"
},
"permissionMode": {
"type": "string",
"enum": ["approve-all", "approve-reads", "deny-all"]
},
"nonInteractivePermissions": {
"type": "string",
"enum": ["deny", "fail"]
},
"timeoutSeconds": {
"type": "number",
"minimum": 0.001
},
"queueOwnerTtlSeconds": {
"type": "number",
"minimum": 0
}
}
},
"uiHints": {
"cwd": {
"label": "Default Working Directory",
"help": "Default cwd for ACP session operations when not set per session."
},
"permissionMode": {
"label": "Permission Mode",
"help": "Default acpx permission policy for runtime prompts."
},
"nonInteractivePermissions": {
"label": "Non-Interactive Permission Policy",
"help": "acpx policy when interactive permission prompts are unavailable."
},
"timeoutSeconds": {
"label": "Prompt Timeout Seconds",
"help": "Optional acpx timeout for each runtime turn.",
"advanced": true
},
"queueOwnerTtlSeconds": {
"label": "Queue Owner TTL Seconds",
"help": "Idle queue-owner TTL for acpx prompt turns. Keep this short in OpenClaw to avoid delayed completion after each turn.",
"advanced": true
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@openclaw/acpx",
"version": "2026.2.22",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
"acpx": "^0.1.13"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,209 @@
---
name: acp-router
description: Route plain-language requests for Pi, Claude Code, Codex, OpenCode, Gemini CLI, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow).
user-invocable: false
---
# ACP Harness Router
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
## Intent detection
Trigger this skill when the user asks OpenClaw to:
- run something in Pi / Claude Code / Codex / OpenCode / Gemini
- continue existing harness work
- relay instructions to an external coding harness
- keep an external harness conversation in a thread-like conversation
## Mode selection
Choose one of these paths:
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
Use direct `acpx` when one of these is true:
- user explicitly asks for direct `acpx` driving
- ACP runtime/plugin path is unavailable or unhealthy
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
Do not use:
- `subagents` runtime for harness control
- `/acp` command delegation as a requirement for the user
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
## AgentId mapping
Use these defaults when user names a harness directly:
- "pi" -> `agentId: "pi"`
- "claude" or "claude code" -> `agentId: "claude"`
- "codex" -> `agentId: "codex"`
- "opencode" -> `agentId: "opencode"`
- "gemini" or "gemini cli" -> `agentId: "gemini"`
These defaults match current acpx built-in aliases.
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
## OpenClaw ACP runtime path
Required behavior:
1. Use `sessions_spawn` with:
- `runtime: "acp"`
- `thread: true`
- `mode: "session"` (unless user explicitly wants one-shot)
2. Put requested work in `task` so the ACP session gets it immediately.
3. Set `agentId` explicitly unless ACP default agent is known.
4. Do not ask user to run slash commands or CLI when this path works directly.
Example:
User: "spawn a test codex session in thread and tell it to say hi"
Call:
```json
{
"task": "Say hi.",
"runtime": "acp",
"agentId": "codex",
"thread": true,
"mode": "session"
}
```
## Thread spawn recovery policy
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
Required behavior when ACP backend is unavailable:
1. Do not immediately ask the user to pick an alternate path.
2. First attempt automatic local repair:
- ensure plugin-local pinned acpx is installed in `extensions/acpx`
- verify `${ACPX_CMD} --version`
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
4. Retry ACP thread spawn once after repair.
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
When offering fallback, keep ACP first:
- Option 1: retry ACP spawn after showing exact failing step
- Option 2: direct acpx telephone-game flow
Do not default to subagent runtime for these requests.
## ACPX install and version policy (direct acpx path)
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension.
1. Prefer plugin-local binary, not global PATH:
- `./extensions/acpx/node_modules/.bin/acpx`
2. Resolve pinned version from extension dependency:
- `node -e "console.log(require('./extensions/acpx/package.json').dependencies.acpx)"`
3. If binary is missing or version mismatched, install plugin-local pinned version:
- `cd extensions/acpx && npm install --omit=dev --no-save acpx@<pinnedVersion>`
4. Verify before use:
- `./extensions/acpx/node_modules/.bin/acpx --version`
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
Set and reuse:
```bash
ACPX_CMD="./extensions/acpx/node_modules/.bin/acpx"
```
## Direct acpx path ("telephone game")
Use this path to drive harness sessions without `/acp` or subagent runtime.
### Rules
1. Use `exec` commands that call `${ACPX_CMD}`.
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
3. Prefer `--format quiet` for clean assistant text to relay back to user.
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
### Session naming
Use a deterministic name, for example:
- `oc-<harness>-<conversationId>`
Where `conversationId` is thread id when available, otherwise channel/conversation id.
### Command templates
Persistent session (create if missing, then prompt):
```bash
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
```
One-shot:
```bash
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
```
Cancel in-flight turn:
```bash
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
```
Close session:
```bash
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
```
### Harness aliases in acpx
- `pi`
- `claude`
- `codex`
- `opencode`
- `gemini`
### Built-in adapter commands in acpx
Defaults are:
- `pi -> npx pi-acp`
- `claude -> npx -y @zed-industries/claude-agent-acp`
- `codex -> npx @zed-industries/codex-acp`
- `opencode -> npx -y opencode-ai acp`
- `gemini -> gemini`
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
### Failure handling
- `acpx: command not found`:
- for thread-spawn ACP requests, install plugin-local pinned acpx in `extensions/acpx` immediately
- restart gateway after install and offer to run the restart automatically
- then retry once
- do not ask for install permission first unless policy explicitly requires it
- do not install global `acpx` unless explicitly requested
- adapter command missing (for example `claude-agent-acp` not found):
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
- then retry once before offering fallback
- if user wants binary-based overrides, install exactly the configured adapter binary
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
### Output relay
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.

View File

@@ -0,0 +1,53 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
ACPX_BUNDLED_BIN,
createAcpxPluginConfigSchema,
resolveAcpxPluginConfig,
} from "./config.js";
describe("acpx plugin config parsing", () => {
it("resolves a strict plugin-local acpx command", () => {
const resolved = resolveAcpxPluginConfig({
rawConfig: {
cwd: "/tmp/workspace",
},
workspaceDir: "/tmp/workspace",
});
expect(resolved.command).toBe(ACPX_BUNDLED_BIN);
expect(resolved.cwd).toBe(path.resolve("/tmp/workspace"));
});
it("rejects command overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
command: "acpx-custom",
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: command");
});
it("rejects commandArgs overrides", () => {
expect(() =>
resolveAcpxPluginConfig({
rawConfig: {
commandArgs: ["--foo"],
},
workspaceDir: "/tmp/workspace",
}),
).toThrow("unknown config key: commandArgs");
});
it("schema rejects empty cwd", () => {
const schema = createAcpxPluginConfigSchema();
if (!schema.safeParse) {
throw new Error("acpx config schema missing safeParse");
}
const parsed = schema.safeParse({ cwd: " " });
expect(parsed.success).toBe(false);
});
});

View File

@@ -0,0 +1,196 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
export const ACPX_PINNED_VERSION = "0.1.13";
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
export const ACPX_LOCAL_INSTALL_COMMAND = `npm install --omit=dev --no-save acpx@${ACPX_PINNED_VERSION}`;
export type AcpxPluginConfig = {
cwd?: string;
permissionMode?: AcpxPermissionMode;
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
timeoutSeconds?: number;
queueOwnerTtlSeconds?: number;
};
export type ResolvedAcpxPluginConfig = {
command: string;
cwd: string;
permissionMode: AcpxPermissionMode;
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
timeoutSeconds?: number;
queueOwnerTtlSeconds: number;
};
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
type ParseResult =
| { ok: true; value: AcpxPluginConfig | undefined }
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPermissionMode(value: string): value is AcpxPermissionMode {
return ACPX_PERMISSION_MODES.includes(value as AcpxPermissionMode);
}
function isNonInteractivePermissionPolicy(
value: string,
): value is AcpxNonInteractivePermissionPolicy {
return ACPX_NON_INTERACTIVE_POLICIES.includes(value as AcpxNonInteractivePermissionPolicy);
}
function parseAcpxPluginConfig(value: unknown): ParseResult {
if (value === undefined) {
return { ok: true, value: undefined };
}
if (!isRecord(value)) {
return { ok: false, message: "expected config object" };
}
const allowedKeys = new Set([
"cwd",
"permissionMode",
"nonInteractivePermissions",
"timeoutSeconds",
"queueOwnerTtlSeconds",
]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
return { ok: false, message: `unknown config key: ${key}` };
}
}
const cwd = value.cwd;
if (cwd !== undefined && (typeof cwd !== "string" || cwd.trim() === "")) {
return { ok: false, message: "cwd must be a non-empty string" };
}
const permissionMode = value.permissionMode;
if (
permissionMode !== undefined &&
(typeof permissionMode !== "string" || !isPermissionMode(permissionMode))
) {
return {
ok: false,
message: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
};
}
const nonInteractivePermissions = value.nonInteractivePermissions;
if (
nonInteractivePermissions !== undefined &&
(typeof nonInteractivePermissions !== "string" ||
!isNonInteractivePermissionPolicy(nonInteractivePermissions))
) {
return {
ok: false,
message: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
};
}
const timeoutSeconds = value.timeoutSeconds;
if (
timeoutSeconds !== undefined &&
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)
) {
return { ok: false, message: "timeoutSeconds must be a positive number" };
}
const queueOwnerTtlSeconds = value.queueOwnerTtlSeconds;
if (
queueOwnerTtlSeconds !== undefined &&
(typeof queueOwnerTtlSeconds !== "number" ||
!Number.isFinite(queueOwnerTtlSeconds) ||
queueOwnerTtlSeconds < 0)
) {
return { ok: false, message: "queueOwnerTtlSeconds must be a non-negative number" };
}
return {
ok: true,
value: {
cwd: typeof cwd === "string" ? cwd.trim() : undefined,
permissionMode: typeof permissionMode === "string" ? permissionMode : undefined,
nonInteractivePermissions:
typeof nonInteractivePermissions === "string" ? nonInteractivePermissions : undefined,
timeoutSeconds: typeof timeoutSeconds === "number" ? timeoutSeconds : undefined,
queueOwnerTtlSeconds:
typeof queueOwnerTtlSeconds === "number" ? queueOwnerTtlSeconds : undefined,
},
};
}
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
return {
safeParse(value: unknown):
| { success: true; data?: unknown }
| {
success: false;
error: { issues: Array<{ path: Array<string | number>; message: string }> };
} {
const parsed = parseAcpxPluginConfig(value);
if (parsed.ok) {
return { success: true, data: parsed.value };
}
return {
success: false,
error: {
issues: [{ path: [], message: parsed.message }],
},
};
},
jsonSchema: {
type: "object",
additionalProperties: false,
properties: {
cwd: { type: "string" },
permissionMode: {
type: "string",
enum: [...ACPX_PERMISSION_MODES],
},
nonInteractivePermissions: {
type: "string",
enum: [...ACPX_NON_INTERACTIVE_POLICIES],
},
timeoutSeconds: { type: "number", minimum: 0.001 },
queueOwnerTtlSeconds: { type: "number", minimum: 0 },
},
},
};
}
export function resolveAcpxPluginConfig(params: {
rawConfig: unknown;
workspaceDir?: string;
}): ResolvedAcpxPluginConfig {
const parsed = parseAcpxPluginConfig(params.rawConfig);
if (!parsed.ok) {
throw new Error(parsed.message);
}
const normalized = parsed.value ?? {};
const fallbackCwd = params.workspaceDir?.trim() || process.cwd();
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
return {
command: ACPX_BUNDLED_BIN,
cwd,
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
nonInteractivePermissions:
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
timeoutSeconds: normalized.timeoutSeconds,
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
};
}

View File

@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION } from "./config.js";
const { resolveSpawnFailureMock, spawnAndCollectMock } = vi.hoisted(() => ({
resolveSpawnFailureMock: vi.fn(() => null),
spawnAndCollectMock: vi.fn(),
}));
vi.mock("./runtime-internals/process.js", () => ({
resolveSpawnFailure: resolveSpawnFailureMock,
spawnAndCollect: spawnAndCollectMock,
}));
import { checkPinnedAcpxVersion, ensurePinnedAcpx } from "./ensure.js";
describe("acpx ensure", () => {
beforeEach(() => {
resolveSpawnFailureMock.mockReset();
resolveSpawnFailureMock.mockReturnValue(null);
spawnAndCollectMock.mockReset();
});
it("accepts the pinned acpx version", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
const result = await checkPinnedAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toEqual({
ok: true,
version: ACPX_PINNED_VERSION,
expectedVersion: ACPX_PINNED_VERSION,
});
});
it("reports version mismatch", async () => {
spawnAndCollectMock.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
});
const result = await checkPinnedAcpxVersion({
command: "/plugin/node_modules/.bin/acpx",
cwd: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(result).toMatchObject({
ok: false,
reason: "version-mismatch",
expectedVersion: ACPX_PINNED_VERSION,
installedVersion: "0.0.9",
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
});
});
it("installs and verifies pinned acpx when precheck fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "added 1 package\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: `acpx ${ACPX_PINNED_VERSION}\n`,
stderr: "",
code: 0,
error: null,
});
await ensurePinnedAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
});
expect(spawnAndCollectMock).toHaveBeenCalledTimes(3);
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
cwd: "/plugin",
});
});
it("fails with actionable error when npm install fails", async () => {
spawnAndCollectMock
.mockResolvedValueOnce({
stdout: "acpx 0.0.9\n",
stderr: "",
code: 0,
error: null,
})
.mockResolvedValueOnce({
stdout: "",
stderr: "network down",
code: 1,
error: null,
});
await expect(
ensurePinnedAcpx({
command: "/plugin/node_modules/.bin/acpx",
pluginRoot: "/plugin",
expectedVersion: ACPX_PINNED_VERSION,
}),
).rejects.toThrow("failed to install plugin-local acpx");
});
});

View File

@@ -0,0 +1,169 @@
import type { PluginLogger } from "openclaw/plugin-sdk";
import { ACPX_LOCAL_INSTALL_COMMAND, ACPX_PINNED_VERSION, ACPX_PLUGIN_ROOT } from "./config.js";
import { resolveSpawnFailure, spawnAndCollect } from "./runtime-internals/process.js";
const SEMVER_PATTERN = /\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/;
export type AcpxVersionCheckResult =
| {
ok: true;
version: string;
expectedVersion: string;
}
| {
ok: false;
reason: "missing-command" | "missing-version" | "version-mismatch" | "execution-failed";
message: string;
expectedVersion: string;
installCommand: string;
installedVersion?: string;
};
function extractVersion(stdout: string, stderr: string): string | null {
const combined = `${stdout}\n${stderr}`;
const match = combined.match(SEMVER_PATTERN);
return match?.[0] ?? null;
}
export async function checkPinnedAcpxVersion(params: {
command: string;
cwd?: string;
expectedVersion?: string;
}): Promise<AcpxVersionCheckResult> {
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
const cwd = params.cwd ?? ACPX_PLUGIN_ROOT;
const result = await spawnAndCollect({
command: params.command,
args: ["--version"],
cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, cwd);
if (spawnFailure === "missing-command") {
return {
ok: false,
reason: "missing-command",
message: `acpx command not found at ${params.command}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
return {
ok: false,
reason: "execution-failed",
message: result.error.message,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if ((result.code ?? 0) !== 0) {
const stderr = result.stderr.trim();
return {
ok: false,
reason: "execution-failed",
message: stderr || `acpx --version failed with code ${result.code ?? "unknown"}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
const installedVersion = extractVersion(result.stdout, result.stderr);
if (!installedVersion) {
return {
ok: false,
reason: "missing-version",
message: "acpx --version output did not include a parseable version",
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if (installedVersion !== expectedVersion) {
return {
ok: false,
reason: "version-mismatch",
message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
expectedVersion,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
installedVersion,
};
}
return {
ok: true,
version: installedVersion,
expectedVersion,
};
}
let pendingEnsure: Promise<void> | null = null;
export async function ensurePinnedAcpx(params: {
command: string;
logger?: PluginLogger;
pluginRoot?: string;
expectedVersion?: string;
}): Promise<void> {
if (pendingEnsure) {
return await pendingEnsure;
}
pendingEnsure = (async () => {
const pluginRoot = params.pluginRoot ?? ACPX_PLUGIN_ROOT;
const expectedVersion = params.expectedVersion ?? ACPX_PINNED_VERSION;
const precheck = await checkPinnedAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
});
if (precheck.ok) {
return;
}
params.logger?.warn(
`acpx local binary unavailable or mismatched (${precheck.message}); running plugin-local install`,
);
const install = await spawnAndCollect({
command: "npm",
args: ["install", "--omit=dev", "--no-save", `acpx@${expectedVersion}`],
cwd: pluginRoot,
});
if (install.error) {
const spawnFailure = resolveSpawnFailure(install.error, pluginRoot);
if (spawnFailure === "missing-command") {
throw new Error("npm is required to install plugin-local acpx but was not found on PATH");
}
throw new Error(`failed to install plugin-local acpx: ${install.error.message}`);
}
if ((install.code ?? 0) !== 0) {
const stderr = install.stderr.trim();
const stdout = install.stdout.trim();
const detail = stderr || stdout || `npm exited with code ${install.code ?? "unknown"}`;
throw new Error(`failed to install plugin-local acpx: ${detail}`);
}
const postcheck = await checkPinnedAcpxVersion({
command: params.command,
cwd: pluginRoot,
expectedVersion,
});
if (!postcheck.ok) {
throw new Error(`plugin-local acpx verification failed after install: ${postcheck.message}`);
}
params.logger?.info(`acpx plugin-local binary ready (version ${postcheck.version})`);
})();
try {
await pendingEnsure;
} finally {
pendingEnsure = null;
}
}

View File

@@ -0,0 +1,140 @@
import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
import {
asOptionalBoolean,
asOptionalString,
asString,
asTrimmedString,
type AcpxErrorEvent,
type AcpxJsonObject,
isRecord,
} from "./shared.js";
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
if (!isRecord(value)) {
return null;
}
if (asTrimmedString(value.type) !== "error") {
return null;
}
return {
message: asTrimmedString(value.message) || "acpx reported an error",
code: asOptionalString(value.code),
retryable: asOptionalBoolean(value.retryable),
};
}
export function parseJsonLines(value: string): AcpxJsonObject[] {
const events: AcpxJsonObject[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (isRecord(parsed)) {
events.push(parsed);
}
} catch {
// Ignore malformed lines; callers handle missing typed events via exit code.
}
}
return events;
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return {
type: "status",
text: trimmed,
};
}
if (!isRecord(parsed)) {
return null;
}
const type = asTrimmedString(parsed.type);
switch (type) {
case "text": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "output",
};
}
case "thought": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "thought",
};
}
case "tool_call": {
const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
const status = asTrimmedString(parsed.status);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
};
}
case "client_operation": {
const method = asTrimmedString(parsed.method) || "operation";
const status = asTrimmedString(parsed.status);
const summary = asTrimmedString(parsed.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text };
}
case "plan": {
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
if (!content) {
return null;
}
return { type: "status", text: `plan: ${content}` };
}
case "update": {
const update = asTrimmedString(parsed.update);
if (!update) {
return null;
}
return { type: "status", text: update };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(parsed.stopReason),
};
}
case "error": {
const message = asTrimmedString(parsed.message) || "acpx runtime error";
return {
type: "error",
message,
code: asOptionalString(parsed.code),
retryable: asOptionalBoolean(parsed.retryable),
};
}
default:
return null;
}
}

View File

@@ -0,0 +1,137 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
export type SpawnExit = {
code: number | null;
signal: NodeJS.Signals | null;
error: Error | null;
};
type ResolvedSpawnCommand = {
command: string;
args: string[];
shell?: boolean;
};
function resolveSpawnCommand(params: { command: string; args: string[] }): ResolvedSpawnCommand {
if (process.platform !== "win32") {
return { command: params.command, args: params.args };
}
const extension = path.extname(params.command).toLowerCase();
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
return {
command: process.execPath,
args: [params.command, ...params.args],
};
}
if (extension === ".cmd" || extension === ".bat") {
return {
command: params.command,
args: params.args,
shell: true,
};
}
return {
command: params.command,
args: params.args,
};
}
export function spawnWithResolvedCommand(params: {
command: string;
args: string[];
cwd: string;
}): ChildProcessWithoutNullStreams {
const resolved = resolveSpawnCommand({
command: params.command,
args: params.args,
});
return spawn(resolved.command, resolved.args, {
cwd: params.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: resolved.shell,
});
}
export async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<SpawnExit> {
return await new Promise<SpawnExit>((resolve) => {
let settled = false;
const finish = (result: SpawnExit) => {
if (settled) {
return;
}
settled = true;
resolve(result);
};
child.once("error", (err) => {
finish({ code: null, signal: null, error: err });
});
child.once("close", (code, signal) => {
finish({ code, signal, error: null });
});
});
}
export async function spawnAndCollect(params: {
command: string;
args: string[];
cwd: string;
}): Promise<{
stdout: string;
stderr: string;
code: number | null;
error: Error | null;
}> {
const child = spawnWithResolvedCommand(params);
child.stdin.end();
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
const exit = await waitForExit(child);
return {
stdout,
stderr,
code: exit.code,
error: exit.error,
};
}
export function resolveSpawnFailure(
err: unknown,
cwd: string,
): "missing-command" | "missing-cwd" | null {
if (!err || typeof err !== "object") {
return null;
}
const code = (err as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
return null;
}
return directoryExists(cwd) ? "missing-command" : "missing-cwd";
}
function directoryExists(cwd: string): boolean {
if (!cwd) {
return false;
}
try {
return existsSync(cwd);
} catch {
return false;
}
}

View File

@@ -0,0 +1,56 @@
import type { ResolvedAcpxPluginConfig } from "../config.js";
export type AcpxHandleState = {
name: string;
agent: string;
cwd: string;
mode: "persistent" | "oneshot";
acpxRecordId?: string;
backendSessionId?: string;
agentSessionId?: string;
};
export type AcpxJsonObject = Record<string, unknown>;
export type AcpxErrorEvent = {
message: string;
code?: string;
retryable?: boolean;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
export function asOptionalString(value: unknown): string | undefined {
const text = asTrimmedString(value);
return text || undefined;
}
export function asOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
const match = sessionKey.match(/^agent:([^:]+):/i);
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
return candidate || fallbackAgent;
}
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
if (mode === "approve-all") {
return ["--approve-all"];
}
if (mode === "deny-all") {
return ["--deny-all"];
}
return ["--approve-reads"];
}

View File

@@ -0,0 +1,619 @@
import fs from "node:fs";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { ACPX_PINNED_VERSION, type ResolvedAcpxPluginConfig } from "./config.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
const NOOP_LOGGER = {
info: (_message: string) => {},
warn: (_message: string) => {},
error: (_message: string) => {},
debug: (_message: string) => {},
};
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
const logPath = process.env.MOCK_ACPX_LOG;
const writeLog = (entry) => {
if (!logPath) return;
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
};
if (args.includes("--version")) {
process.stdout.write("mock-acpx ${ACPX_PINNED_VERSION}\\n");
process.exit(0);
}
if (args.includes("--help")) {
process.stdout.write("mock-acpx help\\n");
process.exit(0);
}
const commandIndex = args.findIndex(
(arg) =>
arg === "prompt" ||
arg === "cancel" ||
arg === "sessions" ||
arg === "set-mode" ||
arg === "set" ||
arg === "status",
);
const command = commandIndex >= 0 ? args[commandIndex] : "";
const agent = commandIndex > 0 ? args[commandIndex - 1] : "unknown";
const readFlag = (flag) => {
const idx = args.indexOf(flag);
if (idx < 0) return "";
return String(args[idx + 1] || "");
};
const sessionFromOption = readFlag("--session");
const ensureName = readFlag("--name");
const closeName = command === "sessions" && args[commandIndex + 1] === "close" ? String(args[commandIndex + 2] || "") : "";
const setModeValue = command === "set-mode" ? String(args[commandIndex + 1] || "") : "";
const setKey = command === "set" ? String(args[commandIndex + 1] || "") : "";
const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
process.stdout.write(JSON.stringify({
type: "session_ensured",
acpxRecordId: "rec-" + ensureName,
acpxSessionId: "sid-" + ensureName,
agentSessionId: "inner-" + ensureName,
name: ensureName,
created: true,
}) + "\n");
process.exit(0);
}
if (command === "cancel") {
writeLog({ kind: "cancel", agent, args, sessionName: sessionFromOption });
process.stdout.write(JSON.stringify({
acpxSessionId: "sid-" + sessionFromOption,
cancelled: true,
}) + "\n");
process.exit(0);
}
if (command === "set-mode") {
writeLog({ kind: "set-mode", agent, args, sessionName: sessionFromOption, mode: setModeValue });
process.stdout.write(JSON.stringify({
type: "mode_set",
acpxSessionId: "sid-" + sessionFromOption,
mode: setModeValue,
}) + "\n");
process.exit(0);
}
if (command === "set") {
writeLog({
kind: "set",
agent,
args,
sessionName: sessionFromOption,
key: setKey,
value: setValue,
});
process.stdout.write(JSON.stringify({
type: "config_set",
acpxSessionId: "sid-" + sessionFromOption,
key: setKey,
value: setValue,
}) + "\n");
process.exit(0);
}
if (command === "status") {
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
process.stdout.write(JSON.stringify({
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
status: sessionFromOption ? "alive" : "no-session",
pid: 4242,
uptime: 120,
}) + "\n");
process.exit(0);
}
if (command === "sessions" && args[commandIndex + 1] === "close") {
writeLog({ kind: "close", agent, args, sessionName: closeName });
process.stdout.write(JSON.stringify({
type: "session_closed",
acpxRecordId: "rec-" + closeName,
acpxSessionId: "sid-" + closeName,
name: closeName,
}) + "\n");
process.exit(0);
}
if (command === "prompt") {
const stdinText = fs.readFileSync(0, "utf8");
writeLog({ kind: "prompt", agent, args, sessionName: sessionFromOption, stdinText });
const acpxSessionId = "sid-" + sessionFromOption;
if (stdinText.includes("trigger-error")) {
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "error",
code: "RUNTIME",
message: "mock failure",
}) + "\n");
process.exit(1);
}
if (stdinText.includes("split-spacing")) {
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "text",
content: "alpha",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 1,
stream: "prompt",
type: "text",
content: " beta",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 2,
stream: "prompt",
type: "text",
content: " gamma",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 3,
stream: "prompt",
type: "done",
stopReason: "end_turn",
}) + "\n");
process.exit(0);
}
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 0,
stream: "prompt",
type: "thought",
content: "thinking",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 1,
stream: "prompt",
type: "tool_call",
title: "run-tests",
status: "in_progress",
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 2,
stream: "prompt",
type: "text",
content: "echo:" + stdinText.trim(),
}) + "\n");
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId,
requestId: "req-1",
seq: 3,
stream: "prompt",
type: "done",
stopReason: "end_turn",
}) + "\n");
process.exit(0);
}
writeLog({ kind: "unknown", args });
process.stdout.write(JSON.stringify({
eventVersion: 1,
acpxSessionId: "unknown",
seq: 0,
stream: "control",
type: "error",
code: "USAGE",
message: "unknown command",
}) + "\n");
process.exit(2);
`;
const tempDirs: string[] = [];
async function createMockRuntime(params?: {
permissionMode?: ResolvedAcpxPluginConfig["permissionMode"];
queueOwnerTtlSeconds?: number;
}): Promise<{
runtime: AcpxRuntime;
logPath: string;
config: ResolvedAcpxPluginConfig;
}> {
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-runtime-test-"));
tempDirs.push(dir);
const scriptPath = path.join(dir, "mock-acpx.cjs");
const logPath = path.join(dir, "calls.log");
await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
await chmod(scriptPath, 0o755);
process.env.MOCK_ACPX_LOG = logPath;
const config: ResolvedAcpxPluginConfig = {
command: scriptPath,
cwd: dir,
permissionMode: params?.permissionMode ?? "approve-all",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds ?? 0.1,
};
return {
runtime: new AcpxRuntime(config, {
queueOwnerTtlSeconds: params?.queueOwnerTtlSeconds,
logger: NOOP_LOGGER,
}),
logPath,
config,
};
}
async function readLogEntries(logPath: string): Promise<Array<Record<string, unknown>>> {
if (!fs.existsSync(logPath)) {
return [];
}
const raw = await readFile(logPath, "utf8");
return raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
afterEach(async () => {
delete process.env.MOCK_ACPX_LOG;
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
await rm(dir, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 10,
});
}
});
describe("AcpxRuntime", () => {
it("passes the shared ACP adapter contract suite", async () => {
const fixture = await createMockRuntime();
await runAcpRuntimeAdapterContract({
createRuntime: async () => fixture.runtime,
agentId: "codex",
successPrompt: "contract-pass",
errorPrompt: "trigger-error",
assertSuccessEvents: (events) => {
expect(events.some((event) => event.type === "done")).toBe(true);
},
assertErrorOutcome: ({ events, thrown }) => {
expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
},
});
const logs = await readLogEntries(fixture.logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
expect(logs.some((entry) => entry.kind === "status")).toBe(true);
expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
expect(logs.some((entry) => entry.kind === "set")).toBe(true);
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
});
it("ensures sessions and streams prompt events", async () => {
const { runtime, logPath } = await createMockRuntime({ queueOwnerTtlSeconds: 180 });
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:123",
agent: "codex",
mode: "persistent",
});
expect(handle.backend).toBe("acpx");
expect(handle.acpxRecordId).toBe("rec-agent:codex:acp:123");
expect(handle.agentSessionId).toBe("inner-agent:codex:acp:123");
expect(handle.backendSessionId).toBe("sid-agent:codex:acp:123");
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
expect(decoded?.acpxRecordId).toBe("rec-agent:codex:acp:123");
expect(decoded?.agentSessionId).toBe("inner-agent:codex:acp:123");
expect(decoded?.backendSessionId).toBe("sid-agent:codex:acp:123");
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "hello world",
mode: "prompt",
requestId: "req-test",
})) {
events.push(event);
}
expect(events).toContainEqual({
type: "text_delta",
text: "thinking",
stream: "thought",
});
expect(events).toContainEqual({
type: "tool_call",
text: "run-tests (in_progress)",
});
expect(events).toContainEqual({
type: "text_delta",
text: "echo:hello world",
stream: "output",
});
expect(events).toContainEqual({
type: "done",
stopReason: "end_turn",
});
const logs = await readLogEntries(logPath);
const ensure = logs.find((entry) => entry.kind === "ensure");
const prompt = logs.find((entry) => entry.kind === "prompt");
expect(ensure).toBeDefined();
expect(prompt).toBeDefined();
expect(Array.isArray(prompt?.args)).toBe(true);
const promptArgs = (prompt?.args as string[]) ?? [];
expect(promptArgs).toContain("--ttl");
expect(promptArgs).toContain("180");
expect(promptArgs).toContain("--approve-all");
});
it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:ttl-default",
agent: "codex",
mode: "persistent",
});
for await (const _event of runtime.runTurn({
handle,
text: "ttl-default",
mode: "prompt",
requestId: "req-ttl-default",
})) {
// drain
}
const logs = await readLogEntries(logPath);
const prompt = logs.find((entry) => entry.kind === "prompt");
expect(prompt).toBeDefined();
const promptArgs = (prompt?.args as string[]) ?? [];
const ttlFlagIndex = promptArgs.indexOf("--ttl");
expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
});
it("preserves leading spaces across streamed text deltas", async () => {
const { runtime } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:space",
agent: "codex",
mode: "persistent",
});
const textDeltas: string[] = [];
for await (const event of runtime.runTurn({
handle,
text: "split-spacing",
mode: "prompt",
requestId: "req-space",
})) {
if (event.type === "text_delta" && event.stream === "output") {
textDeltas.push(event.text);
}
}
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
expect(textDeltas.join("")).toBe("alpha beta gamma");
});
it("maps acpx error events into ACP runtime error events", async () => {
const { runtime } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:456",
agent: "codex",
mode: "persistent",
});
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "trigger-error",
mode: "prompt",
requestId: "req-err",
})) {
events.push(event);
}
expect(events).toContainEqual({
type: "error",
message: "mock failure",
code: "RUNTIME",
retryable: undefined,
});
});
it("supports cancel and close using encoded runtime handle state", async () => {
const { runtime, logPath, config } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:claude:acp:789",
agent: "claude",
mode: "persistent",
});
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
expect(decoded?.name).toBe("agent:claude:acp:789");
const secondRuntime = new AcpxRuntime(config, { logger: NOOP_LOGGER });
await secondRuntime.cancel({ handle, reason: "test" });
await secondRuntime.close({ handle, reason: "test" });
const logs = await readLogEntries(logPath);
const cancel = logs.find((entry) => entry.kind === "cancel");
const close = logs.find((entry) => entry.kind === "close");
expect(cancel?.sessionName).toBe("agent:claude:acp:789");
expect(close?.sessionName).toBe("agent:claude:acp:789");
});
it("exposes control capabilities and runs set-mode/set/status commands", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:controls",
agent: "codex",
mode: "persistent",
});
const capabilities = runtime.getCapabilities();
expect(capabilities.controls).toContain("session/set_mode");
expect(capabilities.controls).toContain("session/set_config_option");
expect(capabilities.controls).toContain("session/status");
await runtime.setMode({
handle,
mode: "plan",
});
await runtime.setConfigOption({
handle,
key: "model",
value: "openai-codex/gpt-5.3-codex",
});
const status = await runtime.getStatus({ handle });
const ensuredSessionName = "agent:codex:acp:controls";
expect(status.summary).toContain("status=alive");
expect(status.acpxRecordId).toBe("rec-" + ensuredSessionName);
expect(status.backendSessionId).toBe("sid-" + ensuredSessionName);
expect(status.agentSessionId).toBe("inner-" + ensuredSessionName);
expect(status.details?.acpxRecordId).toBe("rec-" + ensuredSessionName);
expect(status.details?.status).toBe("alive");
expect(status.details?.pid).toBe(4242);
const logs = await readLogEntries(logPath);
expect(logs.find((entry) => entry.kind === "set-mode")?.mode).toBe("plan");
expect(logs.find((entry) => entry.kind === "set")?.key).toBe("model");
expect(logs.find((entry) => entry.kind === "status")).toBeDefined();
});
it("skips prompt execution when runTurn starts with an already-aborted signal", async () => {
const { runtime, logPath } = await createMockRuntime();
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:aborted",
agent: "codex",
mode: "persistent",
});
const controller = new AbortController();
controller.abort();
const events = [];
for await (const event of runtime.runTurn({
handle,
text: "should-not-run",
mode: "prompt",
requestId: "req-aborted",
signal: controller.signal,
})) {
events.push(event);
}
const logs = await readLogEntries(logPath);
expect(events).toEqual([]);
expect(logs.some((entry) => entry.kind === "prompt")).toBe(false);
});
it("does not mark backend unhealthy when a per-session cwd is missing", async () => {
const { runtime } = await createMockRuntime();
const missingCwd = path.join(os.tmpdir(), "openclaw-acpx-runtime-test-missing-cwd");
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
await expect(
runtime.ensureSession({
sessionKey: "agent:codex:acp:missing-cwd",
agent: "codex",
mode: "persistent",
cwd: missingCwd,
}),
).rejects.toMatchObject({
code: "ACP_SESSION_INIT_FAILED",
message: expect.stringContaining("working directory does not exist"),
});
expect(runtime.isHealthy()).toBe(true);
});
it("marks runtime unhealthy when command is missing", async () => {
const runtime = new AcpxRuntime(
{
command: "/definitely/missing/acpx",
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: 0.1,
},
{ logger: NOOP_LOGGER },
);
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(false);
});
it("marks runtime healthy when command is available", async () => {
const { runtime } = await createMockRuntime();
await runtime.probeAvailability();
expect(runtime.isHealthy()).toBe(true);
});
it("returns doctor report for missing command", async () => {
const runtime = new AcpxRuntime(
{
command: "/definitely/missing/acpx",
cwd: process.cwd(),
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
queueOwnerTtlSeconds: 0.1,
},
{ logger: NOOP_LOGGER },
);
const report = await runtime.doctor();
expect(report.ok).toBe(false);
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
expect(report.installCommand).toContain("acpx");
});
});

View File

@@ -0,0 +1,578 @@
import { createInterface } from "node:readline";
import type {
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntime,
AcpRuntimeEnsureInput,
AcpRuntimeErrorCode,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnInput,
PluginLogger,
} from "openclaw/plugin-sdk";
import { AcpRuntimeError } from "openclaw/plugin-sdk";
import {
ACPX_LOCAL_INSTALL_COMMAND,
ACPX_PINNED_VERSION,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import { checkPinnedAcpxVersion } from "./ensure.js";
import {
parseJsonLines,
parsePromptEventLine,
toAcpxErrorEvent,
} from "./runtime-internals/events.js";
import {
resolveSpawnFailure,
spawnAndCollect,
spawnWithResolvedCommand,
waitForExit,
} from "./runtime-internals/process.js";
import {
asOptionalString,
asTrimmedString,
buildPermissionArgs,
deriveAgentFromSessionKey,
isRecord,
type AcpxHandleState,
type AcpxJsonObject,
} from "./runtime-internals/shared.js";
export const ACPX_BACKEND_ID = "acpx";
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v1:";
const DEFAULT_AGENT_FALLBACK = "codex";
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
controls: ["session/set_mode", "session/set_config_option", "session/status"],
};
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
}
export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
const trimmed = runtimeSessionName.trim();
if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
return null;
}
const encoded = trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length);
if (!encoded) {
return null;
}
try {
const raw = Buffer.from(encoded, "base64url").toString("utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return null;
}
const name = asTrimmedString(parsed.name);
const agent = asTrimmedString(parsed.agent);
const cwd = asTrimmedString(parsed.cwd);
const mode = asTrimmedString(parsed.mode);
const acpxRecordId = asOptionalString(parsed.acpxRecordId);
const backendSessionId = asOptionalString(parsed.backendSessionId);
const agentSessionId = asOptionalString(parsed.agentSessionId);
if (!name || !agent || !cwd) {
return null;
}
if (mode !== "persistent" && mode !== "oneshot") {
return null;
}
return {
name,
agent,
cwd,
mode,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
};
} catch {
return null;
}
}
export class AcpxRuntime implements AcpRuntime {
private healthy = false;
private readonly logger?: PluginLogger;
private readonly queueOwnerTtlSeconds: number;
constructor(
private readonly config: ResolvedAcpxPluginConfig,
opts?: {
logger?: PluginLogger;
queueOwnerTtlSeconds?: number;
},
) {
this.logger = opts?.logger;
const requestedQueueOwnerTtlSeconds = opts?.queueOwnerTtlSeconds;
this.queueOwnerTtlSeconds =
typeof requestedQueueOwnerTtlSeconds === "number" &&
Number.isFinite(requestedQueueOwnerTtlSeconds) &&
requestedQueueOwnerTtlSeconds >= 0
? requestedQueueOwnerTtlSeconds
: this.config.queueOwnerTtlSeconds;
}
isHealthy(): boolean {
return this.healthy;
}
async probeAvailability(): Promise<void> {
const versionCheck = await checkPinnedAcpxVersion({
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: ACPX_PINNED_VERSION,
});
if (!versionCheck.ok) {
this.healthy = false;
return;
}
try {
const result = await spawnAndCollect({
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
});
this.healthy = result.error == null && (result.code ?? 0) === 0;
} catch {
this.healthy = false;
}
}
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
const sessionName = asTrimmedString(input.sessionKey);
if (!sessionName) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
const agent = asTrimmedString(input.agent);
if (!agent) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
}
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
const mode = input.mode;
const events = await this.runControlCommand({
args: this.buildControlArgs({
cwd,
command: [agent, "sessions", "ensure", "--name", sessionName],
}),
cwd,
fallbackCode: "ACP_SESSION_INIT_FAILED",
});
const ensuredEvent = events.find(
(event) =>
asOptionalString(event.agentSessionId) ||
asOptionalString(event.acpxSessionId) ||
asOptionalString(event.acpxRecordId),
);
const acpxRecordId = ensuredEvent ? asOptionalString(ensuredEvent.acpxRecordId) : undefined;
const agentSessionId = ensuredEvent ? asOptionalString(ensuredEvent.agentSessionId) : undefined;
const backendSessionId = ensuredEvent
? asOptionalString(ensuredEvent.acpxSessionId)
: undefined;
return {
sessionKey: input.sessionKey,
backend: ACPX_BACKEND_ID,
runtimeSessionName: encodeAcpxRuntimeHandleState({
name: sessionName,
agent,
cwd,
mode,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
}),
cwd,
...(acpxRecordId ? { acpxRecordId } : {}),
...(backendSessionId ? { backendSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
};
}
async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent> {
const state = this.resolveHandleState(input.handle);
const args = this.buildPromptArgs({
agent: state.agent,
sessionName: state.name,
cwd: state.cwd,
});
const cancelOnAbort = async () => {
await this.cancel({
handle: input.handle,
reason: "abort-signal",
}).catch((err) => {
this.logger?.warn?.(`acpx runtime abort-cancel failed: ${String(err)}`);
});
};
const onAbort = () => {
void cancelOnAbort();
};
if (input.signal?.aborted) {
await cancelOnAbort();
return;
}
if (input.signal) {
input.signal.addEventListener("abort", onAbort, { once: true });
}
const child = spawnWithResolvedCommand({
command: this.config.command,
args,
cwd: state.cwd,
});
child.stdin.on("error", () => {
// Ignore EPIPE when the child exits before stdin flush completes.
});
child.stdin.end(input.text);
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
let sawDone = false;
let sawError = false;
const lines = createInterface({ input: child.stdout });
try {
for await (const line of lines) {
const parsed = parsePromptEventLine(line);
if (!parsed) {
continue;
}
if (parsed.type === "done") {
sawDone = true;
}
if (parsed.type === "error") {
sawError = true;
}
yield parsed;
}
const exit = await waitForExit(child);
if (exit.error) {
const spawnFailure = resolveSpawnFailure(exit.error, state.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
`acpx command not found: ${this.config.command}`,
{ cause: exit.error },
);
}
if (spawnFailure === "missing-cwd") {
throw new AcpRuntimeError(
"ACP_TURN_FAILED",
`ACP runtime working directory does not exist: ${state.cwd}`,
{ cause: exit.error },
);
}
throw new AcpRuntimeError("ACP_TURN_FAILED", exit.error.message, { cause: exit.error });
}
if ((exit.code ?? 0) !== 0 && !sawError) {
yield {
type: "error",
message: stderr.trim() || `acpx exited with code ${exit.code ?? "unknown"}`,
};
return;
}
if (!sawDone && !sawError) {
yield { type: "done" };
}
} finally {
lines.close();
if (input.signal) {
input.signal.removeEventListener("abort", onAbort);
}
}
}
getCapabilities(): AcpRuntimeCapabilities {
return ACPX_CAPABILITIES;
}
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
const state = this.resolveHandleState(input.handle);
const events = await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "status", "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
const detail = events.find((event) => !toAcpxErrorEvent(event)) ?? events[0];
if (!detail) {
return {
summary: "acpx status unavailable",
};
}
const status = asTrimmedString(detail.status) || "unknown";
const acpxRecordId = asOptionalString(detail.acpxRecordId);
const acpxSessionId = asOptionalString(detail.acpxSessionId);
const agentSessionId = asOptionalString(detail.agentSessionId);
const pid = typeof detail.pid === "number" && Number.isFinite(detail.pid) ? detail.pid : null;
const summary = [
`status=${status}`,
acpxRecordId ? `acpxRecordId=${acpxRecordId}` : null,
acpxSessionId ? `acpxSessionId=${acpxSessionId}` : null,
pid != null ? `pid=${pid}` : null,
]
.filter(Boolean)
.join(" ");
return {
summary,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { backendSessionId: acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
details: detail,
};
}
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
const mode = asTrimmedString(input.mode);
if (!mode) {
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP runtime mode is required.");
}
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "set-mode", mode, "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
});
}
async setConfigOption(input: {
handle: AcpRuntimeHandle;
key: string;
value: string;
}): Promise<void> {
const state = this.resolveHandleState(input.handle);
const key = asTrimmedString(input.key);
const value = asTrimmedString(input.value);
if (!key || !value) {
throw new AcpRuntimeError("ACP_TURN_FAILED", "ACP config option key/value are required.");
}
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "set", key, value, "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
});
}
async doctor(): Promise<AcpRuntimeDoctorReport> {
const versionCheck = await checkPinnedAcpxVersion({
command: this.config.command,
cwd: this.config.cwd,
expectedVersion: ACPX_PINNED_VERSION,
});
if (!versionCheck.ok) {
this.healthy = false;
const details = [
`expected=${versionCheck.expectedVersion}`,
versionCheck.installedVersion ? `installed=${versionCheck.installedVersion}` : null,
].filter((detail): detail is string => Boolean(detail));
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: versionCheck.message,
installCommand: versionCheck.installCommand,
details,
};
}
try {
const result = await spawnAndCollect({
command: this.config.command,
args: ["--help"],
cwd: this.config.cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: `acpx command not found: ${this.config.command}`,
installCommand: ACPX_LOCAL_INSTALL_COMMAND,
};
}
if (spawnFailure === "missing-cwd") {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
};
}
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: result.error.message,
details: [String(result.error)],
};
}
if ((result.code ?? 0) !== 0) {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
};
}
this.healthy = true;
return {
ok: true,
message: `acpx command available (${this.config.command}, version ${versionCheck.version})`,
};
} catch (error) {
this.healthy = false;
return {
ok: false,
code: "ACP_BACKEND_UNAVAILABLE",
message: error instanceof Error ? error.message : String(error),
};
}
}
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "cancel", "--session", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
}
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
const state = this.resolveHandleState(input.handle);
await this.runControlCommand({
args: this.buildControlArgs({
cwd: state.cwd,
command: [state.agent, "sessions", "close", state.name],
}),
cwd: state.cwd,
fallbackCode: "ACP_TURN_FAILED",
ignoreNoSession: true,
});
}
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
if (decoded) {
return decoded;
}
const legacyName = asTrimmedString(handle.runtimeSessionName);
if (!legacyName) {
throw new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
"Invalid acpx runtime handle: runtimeSessionName is missing.",
);
}
return {
name: legacyName,
agent: deriveAgentFromSessionKey(handle.sessionKey, DEFAULT_AGENT_FALLBACK),
cwd: this.config.cwd,
mode: "persistent",
};
}
private buildControlArgs(params: { cwd: string; command: string[] }): string[] {
return ["--format", "json", "--json-strict", "--cwd", params.cwd, ...params.command];
}
private buildPromptArgs(params: { agent: string; sessionName: string; cwd: string }): string[] {
const args = [
"--format",
"json",
"--json-strict",
"--cwd",
params.cwd,
...buildPermissionArgs(this.config.permissionMode),
"--non-interactive-permissions",
this.config.nonInteractivePermissions,
];
if (this.config.timeoutSeconds) {
args.push("--timeout", String(this.config.timeoutSeconds));
}
args.push("--ttl", String(this.queueOwnerTtlSeconds));
args.push(params.agent, "prompt", "--session", params.sessionName, "--file", "-");
return args;
}
private async runControlCommand(params: {
args: string[];
cwd: string;
fallbackCode: AcpRuntimeErrorCode;
ignoreNoSession?: boolean;
}): Promise<AcpxJsonObject[]> {
const result = await spawnAndCollect({
command: this.config.command,
args: params.args,
cwd: params.cwd,
});
if (result.error) {
const spawnFailure = resolveSpawnFailure(result.error, params.cwd);
if (spawnFailure === "missing-command") {
this.healthy = false;
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
`acpx command not found: ${this.config.command}`,
{ cause: result.error },
);
}
if (spawnFailure === "missing-cwd") {
throw new AcpRuntimeError(
params.fallbackCode,
`ACP runtime working directory does not exist: ${params.cwd}`,
{ cause: result.error },
);
}
throw new AcpRuntimeError(params.fallbackCode, result.error.message, { cause: result.error });
}
const events = parseJsonLines(result.stdout);
const errorEvent = events.map((event) => toAcpxErrorEvent(event)).find(Boolean) ?? null;
if (errorEvent) {
if (params.ignoreNoSession && errorEvent.code === "NO_SESSION") {
return events;
}
throw new AcpRuntimeError(
params.fallbackCode,
errorEvent.code ? `${errorEvent.code}: ${errorEvent.message}` : errorEvent.message,
);
}
if ((result.code ?? 0) !== 0) {
throw new AcpRuntimeError(
params.fallbackCode,
result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
);
}
return events;
}
}

View File

@@ -0,0 +1,173 @@
import type { AcpRuntime, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../../src/acp/runtime/errors.js";
import {
__testing,
getAcpRuntimeBackend,
requireAcpRuntimeBackend,
} from "../../../src/acp/runtime/registry.js";
import { ACPX_BUNDLED_BIN } from "./config.js";
import { createAcpxRuntimeService } from "./service.js";
const { ensurePinnedAcpxSpy } = vi.hoisted(() => ({
ensurePinnedAcpxSpy: vi.fn(async () => {}),
}));
vi.mock("./ensure.js", () => ({
ensurePinnedAcpx: ensurePinnedAcpxSpy,
}));
type RuntimeStub = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
};
function createRuntimeStub(healthy: boolean): {
runtime: RuntimeStub;
probeAvailabilitySpy: ReturnType<typeof vi.fn>;
isHealthySpy: ReturnType<typeof vi.fn>;
} {
const probeAvailabilitySpy = vi.fn(async () => {});
const isHealthySpy = vi.fn(() => healthy);
return {
runtime: {
ensureSession: vi.fn(async (input) => ({
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: input.sessionKey,
})),
runTurn: vi.fn(async function* () {
yield { type: "done" as const };
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
async probeAvailability() {
await probeAvailabilitySpy();
},
isHealthy() {
return isHealthySpy();
},
},
probeAvailabilitySpy,
isHealthySpy,
};
}
function createServiceContext(
overrides: Partial<OpenClawPluginServiceContext> = {},
): OpenClawPluginServiceContext {
return {
config: {},
workspaceDir: "/tmp/workspace",
stateDir: "/tmp/state",
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
...overrides,
};
}
describe("createAcpxRuntimeService", () => {
beforeEach(() => {
__testing.resetAcpRuntimeBackendsForTests();
ensurePinnedAcpxSpy.mockReset();
ensurePinnedAcpxSpy.mockImplementation(async () => {});
});
it("registers and unregisters the acpx backend", async () => {
const { runtime, probeAvailabilitySpy } = createRuntimeStub(true);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
await service.start(context);
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
await vi.waitFor(() => {
expect(ensurePinnedAcpxSpy).toHaveBeenCalledOnce();
expect(probeAvailabilitySpy).toHaveBeenCalledOnce();
});
await service.stop?.(context);
expect(getAcpRuntimeBackend("acpx")).toBeNull();
});
it("marks backend unavailable when runtime health check fails", async () => {
const { runtime } = createRuntimeStub(false);
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
await service.start(context);
expect(() => requireAcpRuntimeBackend("acpx")).toThrowError(AcpRuntimeError);
try {
requireAcpRuntimeBackend("acpx");
throw new Error("expected ACP backend lookup to fail");
} catch (error) {
expect((error as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
}
});
it("passes queue-owner TTL from plugin config", async () => {
const { runtime } = createRuntimeStub(true);
const runtimeFactory = vi.fn(() => runtime);
const service = createAcpxRuntimeService({
runtimeFactory,
pluginConfig: {
queueOwnerTtlSeconds: 0.25,
},
});
const context = createServiceContext();
await service.start(context);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
queueOwnerTtlSeconds: 0.25,
pluginConfig: expect.objectContaining({
command: ACPX_BUNDLED_BIN,
}),
}),
);
});
it("uses a short default queue-owner TTL", async () => {
const { runtime } = createRuntimeStub(true);
const runtimeFactory = vi.fn(() => runtime);
const service = createAcpxRuntimeService({
runtimeFactory,
});
const context = createServiceContext();
await service.start(context);
expect(runtimeFactory).toHaveBeenCalledWith(
expect.objectContaining({
queueOwnerTtlSeconds: 0.1,
}),
);
});
it("does not block startup while acpx ensure runs", async () => {
const { runtime } = createRuntimeStub(true);
ensurePinnedAcpxSpy.mockImplementation(() => new Promise<void>(() => {}));
const service = createAcpxRuntimeService({
runtimeFactory: () => runtime,
});
const context = createServiceContext();
const startResult = await Promise.race([
Promise.resolve(service.start(context)).then(() => "started"),
new Promise<string>((resolve) => setTimeout(() => resolve("timed_out"), 100)),
]);
expect(startResult).toBe("started");
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
});
});

View File

@@ -0,0 +1,102 @@
import type {
AcpRuntime,
OpenClawPluginService,
OpenClawPluginServiceContext,
PluginLogger,
} from "openclaw/plugin-sdk";
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "openclaw/plugin-sdk";
import {
ACPX_PINNED_VERSION,
resolveAcpxPluginConfig,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import { ensurePinnedAcpx } from "./ensure.js";
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
isHealthy(): boolean;
};
type AcpxRuntimeFactoryParams = {
pluginConfig: ResolvedAcpxPluginConfig;
queueOwnerTtlSeconds: number;
logger?: PluginLogger;
};
type CreateAcpxRuntimeServiceParams = {
pluginConfig?: unknown;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
};
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime(params.pluginConfig, {
logger: params.logger,
queueOwnerTtlSeconds: params.queueOwnerTtlSeconds,
});
}
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
): OpenClawPluginService {
let runtime: AcpxRuntimeLike | null = null;
let lifecycleRevision = 0;
return {
id: "acpx-runtime",
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
const pluginConfig = resolveAcpxPluginConfig({
rawConfig: params.pluginConfig,
workspaceDir: ctx.workspaceDir,
});
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
runtime = runtimeFactory({
pluginConfig,
queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
logger: ctx.logger,
});
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
runtime,
healthy: () => runtime?.isHealthy() ?? false,
});
ctx.logger.info(
`acpx runtime backend registered (command: ${pluginConfig.command}, pinned: ${ACPX_PINNED_VERSION})`,
);
lifecycleRevision += 1;
const currentRevision = lifecycleRevision;
void (async () => {
try {
await ensurePinnedAcpx({
command: pluginConfig.command,
logger: ctx.logger,
expectedVersion: ACPX_PINNED_VERSION,
});
if (currentRevision !== lifecycleRevision) {
return;
}
await runtime?.probeAvailability();
if (runtime?.isHealthy()) {
ctx.logger.info("acpx runtime backend ready");
} else {
ctx.logger.warn("acpx runtime backend probe failed after local install");
}
} catch (err) {
if (currentRevision !== lifecycleRevision) {
return;
}
ctx.logger.warn(
`acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
})();
},
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
lifecycleRevision += 1;
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
runtime = null;
},
};
}

View File

@@ -54,7 +54,7 @@
"build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging",
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused",
@@ -93,6 +93,7 @@
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"mac:open": "open dist/OpenClaw.app",

115
pnpm-lock.yaml generated
View File

@@ -251,6 +251,12 @@ importers:
specifier: ^0.10.0
version: 0.10.0
extensions/acpx:
dependencies:
acpx:
specifier: ^0.1.13
version: 0.1.13(zod@4.3.6)
extensions/bluebubbles: {}
extensions/copilot-proxy: {}
@@ -3045,8 +3051,8 @@ packages:
link-preview-js:
optional: true
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67}
'@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git}
version: 2.0.1
abbrev@1.1.1:
@@ -3074,6 +3080,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acpx@0.1.13:
resolution: {integrity: sha512-C032VkV3cNa13ubq9YhskTWvDTsciNAQfNHZLW3PIN3atdkrzkV0v2yi6Znp7UZDw+pzgpKUsOrZWl64Lwr+3w==}
engines: {node: '>=18'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -3211,10 +3222,26 @@ packages:
axios@1.13.5:
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
b4a@1.8.0:
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -3385,6 +3412,10 @@ packages:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
@@ -3662,6 +3693,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -3692,6 +3726,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
@@ -5178,6 +5215,11 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
skillflag@0.1.4:
resolution: {integrity: sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==}
engines: {node: '>=18'}
hasBin: true
sleep-promise@9.1.0:
resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==}
@@ -5277,6 +5319,9 @@ packages:
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
engines: {node: '>=18'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -5322,11 +5367,17 @@ packages:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
tar@7.5.9:
resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -8693,7 +8744,7 @@ snapshots:
'@cacheable/node-cache': 1.7.6
'@hapi/boom': 9.1.4
async-mutex: 0.5.0
libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67'
lru-cache: 11.2.6
music-metadata: 11.12.1
p-queue: 9.1.0
@@ -8708,7 +8759,7 @@ snapshots:
- supports-color
- utf-8-validate
'@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
'@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67':
dependencies:
curve25519-js: 0.0.4
protobufjs: 6.8.8
@@ -8736,6 +8787,16 @@ snapshots:
acorn@8.16.0: {}
acpx@0.1.13(zod@4.3.6):
dependencies:
'@agentclientprotocol/sdk': 0.14.1(zod@4.3.6)
commander: 13.1.0
skillflag: 0.1.4
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
- zod
agent-base@6.0.2:
dependencies:
debug: 4.4.3
@@ -8875,8 +8936,12 @@ snapshots:
transitivePeerDependencies:
- debug
b4a@1.8.0: {}
balanced-match@4.0.4: {}
bare-events@2.8.2: {}
base64-js@1.5.1: {}
basic-auth@2.0.1:
@@ -9073,6 +9138,8 @@ snapshots:
commander@10.0.1: {}
commander@13.1.0: {}
commander@14.0.3: {}
console-control-strings@1.1.0: {}
@@ -9304,6 +9371,12 @@ snapshots:
eventemitter3@5.0.4: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
expect-type@1.3.0: {}
express@4.22.1:
@@ -9393,6 +9466,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-uri@3.1.0: {}
fast-xml-parser@5.3.6:
@@ -11206,6 +11281,14 @@ snapshots:
sisteransi@1.0.5: {}
skillflag@0.1.4:
dependencies:
'@clack/prompts': 1.0.1
tar-stream: 3.1.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
sleep-promise@9.1.0: {}
slice-ansi@7.1.2:
@@ -11301,6 +11384,15 @@ snapshots:
steno@4.0.2: {}
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -11352,6 +11444,15 @@ snapshots:
array-back: 6.2.2
wordwrapjs: 5.1.1
tar-stream@3.1.7:
dependencies:
b4a: 1.8.0
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
tar@7.5.9:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@@ -11360,6 +11461,12 @@ snapshots:
minizlib: 3.1.0
yallist: 5.0.0
text-decoder@1.2.7:
dependencies:
b4a: 1.8.0
transitivePeerDependencies:
- react-native-b4a
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1

View File

@@ -0,0 +1,405 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const acpCoreProtectedSources = [
path.join(repoRoot, "src", "acp"),
path.join(repoRoot, "src", "agents", "acp-spawn.ts"),
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
path.join(repoRoot, "src", "infra", "outbound", "conversation-id.ts"),
];
const channelCoreProtectedSources = [
path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"),
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
];
const acpUserFacingTextSources = [
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
];
const systemMarkLiteralGuardSources = [
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
path.join(repoRoot, "src", "auto-reply", "reply", "dispatch-acp.ts"),
path.join(repoRoot, "src", "auto-reply", "reply", "directive-handling.shared.ts"),
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
];
const channelIds = [
"bluebubbles",
"discord",
"googlechat",
"imessage",
"irc",
"line",
"matrix",
"msteams",
"signal",
"slack",
"telegram",
"web",
"whatsapp",
"zalo",
"zalouser",
];
const channelIdSet = new Set(channelIds);
const channelSegmentRe = new RegExp(`(^|[._/-])(?:${channelIds.join("|")})([._/-]|$)`);
const comparisonOperators = new Set([
ts.SyntaxKind.EqualsEqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsToken,
]);
const allowedViolations = new Set([]);
function isTestLikeFile(filePath) {
return (
filePath.endsWith(".test.ts") ||
filePath.endsWith(".test-utils.ts") ||
filePath.endsWith(".test-harness.ts") ||
filePath.endsWith(".e2e-harness.ts")
);
}
async function collectTypeScriptFiles(targetPath) {
const stat = await fs.stat(targetPath);
if (stat.isFile()) {
if (!targetPath.endsWith(".ts") || isTestLikeFile(targetPath)) {
return [];
}
return [targetPath];
}
const entries = await fs.readdir(targetPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name);
if (entry.isDirectory()) {
files.push(...(await collectTypeScriptFiles(entryPath)));
continue;
}
if (!entry.isFile()) {
continue;
}
if (!entryPath.endsWith(".ts")) {
continue;
}
if (isTestLikeFile(entryPath)) {
continue;
}
files.push(entryPath);
}
return files;
}
function toLine(sourceFile, node) {
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
}
function isChannelsPropertyAccess(node) {
if (ts.isPropertyAccessExpression(node)) {
return node.name.text === "channels";
}
if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) {
return node.argumentExpression.text === "channels";
}
return false;
}
function readStringLiteral(node) {
if (ts.isStringLiteral(node)) {
return node.text;
}
if (ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
return null;
}
function isChannelLiteralNode(node) {
const text = readStringLiteral(node);
return text ? channelIdSet.has(text) : false;
}
function matchesChannelModuleSpecifier(specifier) {
return channelSegmentRe.test(specifier.replaceAll("\\", "/"));
}
function getPropertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
const userFacingChannelNameRe =
/\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams|bluebubbles)\b/i;
const systemMarkLiteral = "⚙️";
function isModuleSpecifierStringNode(node) {
const parent = node.parent;
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) {
return true;
}
return (
ts.isCallExpression(parent) &&
parent.expression.kind === ts.SyntaxKind.ImportKeyword &&
parent.arguments[0] === node
);
}
export function findChannelAgnosticBoundaryViolations(
content,
fileName = "source.ts",
options = {},
) {
const checkModuleSpecifiers = options.checkModuleSpecifiers ?? true;
const checkConfigPaths = options.checkConfigPaths ?? true;
const checkChannelComparisons = options.checkChannelComparisons ?? true;
const checkChannelAssignments = options.checkChannelAssignments ?? true;
const moduleSpecifierMatcher = options.moduleSpecifierMatcher ?? matchesChannelModuleSpecifier;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
if (
checkModuleSpecifiers &&
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const specifier = node.moduleSpecifier.text;
if (moduleSpecifierMatcher(specifier)) {
violations.push({
line: toLine(sourceFile, node.moduleSpecifier),
reason: `imports channel module "${specifier}"`,
});
}
}
if (
checkModuleSpecifiers &&
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
const specifier = node.moduleSpecifier.text;
if (moduleSpecifierMatcher(specifier)) {
violations.push({
line: toLine(sourceFile, node.moduleSpecifier),
reason: `re-exports channel module "${specifier}"`,
});
}
}
if (
checkModuleSpecifiers &&
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
node.arguments.length > 0 &&
ts.isStringLiteral(node.arguments[0])
) {
const specifier = node.arguments[0].text;
if (moduleSpecifierMatcher(specifier)) {
violations.push({
line: toLine(sourceFile, node.arguments[0]),
reason: `dynamically imports channel module "${specifier}"`,
});
}
}
if (
checkConfigPaths &&
ts.isPropertyAccessExpression(node) &&
channelIdSet.has(node.name.text)
) {
if (isChannelsPropertyAccess(node.expression)) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references config path "channels.${node.name.text}"`,
});
}
}
if (
checkConfigPaths &&
ts.isElementAccessExpression(node) &&
ts.isStringLiteral(node.argumentExpression) &&
channelIdSet.has(node.argumentExpression.text)
) {
if (isChannelsPropertyAccess(node.expression)) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references config path "channels[${JSON.stringify(node.argumentExpression.text)}]"`,
});
}
}
if (
checkChannelComparisons &&
ts.isBinaryExpression(node) &&
comparisonOperators.has(node.operatorToken.kind)
) {
if (isChannelLiteralNode(node.left) || isChannelLiteralNode(node.right)) {
const leftText = node.left.getText(sourceFile);
const rightText = node.right.getText(sourceFile);
violations.push({
line: toLine(sourceFile, node.operatorToken),
reason: `compares with channel id literal (${leftText} ${node.operatorToken.getText(sourceFile)} ${rightText})`,
});
}
}
if (checkChannelAssignments && ts.isPropertyAssignment(node)) {
const propName = getPropertyNameText(node.name);
if (propName === "channel" && isChannelLiteralNode(node.initializer)) {
violations.push({
line: toLine(sourceFile, node.initializer),
reason: `assigns channel id literal to "channel" (${node.initializer.getText(sourceFile)})`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export function findChannelCoreReverseDependencyViolations(content, fileName = "source.ts") {
return findChannelAgnosticBoundaryViolations(content, fileName, {
checkModuleSpecifiers: true,
checkConfigPaths: false,
checkChannelComparisons: false,
checkChannelAssignments: false,
moduleSpecifierMatcher: matchesChannelModuleSpecifier,
});
}
export function findAcpUserFacingChannelNameViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
const text = readStringLiteral(node);
if (text && userFacingChannelNameRe.test(text) && !isModuleSpecifierStringNode(node)) {
violations.push({
line: toLine(sourceFile, node),
reason: `user-facing text references channel name (${JSON.stringify(text)})`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export function findSystemMarkLiteralViolations(content, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = [];
const visit = (node) => {
const text = readStringLiteral(node);
if (text && text.includes(systemMarkLiteral) && !isModuleSpecifierStringNode(node)) {
violations.push({
line: toLine(sourceFile, node),
reason: `hardcoded system mark literal (${JSON.stringify(text)})`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
const boundaryRuleSets = [
{
id: "acp-core",
sources: acpCoreProtectedSources,
scan: (content, fileName) => findChannelAgnosticBoundaryViolations(content, fileName),
},
{
id: "channel-core-reverse-deps",
sources: channelCoreProtectedSources,
scan: (content, fileName) => findChannelCoreReverseDependencyViolations(content, fileName),
},
{
id: "acp-user-facing-text",
sources: acpUserFacingTextSources,
scan: (content, fileName) => findAcpUserFacingChannelNameViolations(content, fileName),
},
{
id: "system-mark-literal-usage",
sources: systemMarkLiteralGuardSources,
scan: (content, fileName) => findSystemMarkLiteralViolations(content, fileName),
},
];
export async function main() {
const violations = [];
for (const ruleSet of boundaryRuleSets) {
const files = (
await Promise.all(
ruleSet.sources.map(async (sourcePath) => {
try {
return await collectTypeScriptFiles(sourcePath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}),
)
).flat();
for (const filePath of files) {
const relativeFile = path.relative(repoRoot, filePath);
if (
allowedViolations.has(`${ruleSet.id}:${relativeFile}`) ||
allowedViolations.has(relativeFile)
) {
continue;
}
const content = await fs.readFile(filePath, "utf8");
for (const violation of ruleSet.scan(content, relativeFile)) {
violations.push(`${ruleSet.id} ${relativeFile}:${violation.line}: ${violation.reason}`);
}
}
}
if (violations.length === 0) {
return;
}
console.error("Found channel-specific references in channel-agnostic sources:");
for (const violation of violations) {
console.error(`- ${violation}`);
}
console.error(
"Move channel-specific logic to channel adapters or add a justified allowlist entry.",
);
process.exit(1);
}
const isDirectExecution = (() => {
const entry = process.argv[1];
if (!entry) {
return false;
}
return path.resolve(entry) === fileURLToPath(import.meta.url);
})();
if (isDirectExecution) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}

View File

@@ -0,0 +1,779 @@
#!/usr/bin/env bun
// Manual ACP thread smoke for plain-language routing.
// Keep this script available for regression/debug validation. Do not delete.
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
type ThreadBindingRecord = {
accountId?: string;
channelId?: string;
threadId?: string;
targetKind?: string;
targetSessionKey?: string;
agentId?: string;
boundBy?: string;
boundAt?: number;
};
type ThreadBindingsPayload = {
version?: number;
bindings?: Record<string, ThreadBindingRecord>;
};
type DiscordMessage = {
id: string;
content?: string;
timestamp?: string;
author?: {
id?: string;
username?: string;
bot?: boolean;
};
};
type DiscordUser = {
id: string;
username: string;
bot?: boolean;
};
type DriverMode = "token" | "webhook";
type Args = {
channelId: string;
driverMode: DriverMode;
driverToken: string;
driverTokenPrefix: string;
botToken: string;
botTokenPrefix: string;
targetAgent: string;
timeoutMs: number;
pollMs: number;
mentionUserId?: string;
instruction?: string;
threadBindingsPath: string;
json: boolean;
};
type SuccessResult = {
ok: true;
smokeId: string;
ackToken: string;
sentMessageId: string;
binding: {
threadId: string;
targetSessionKey: string;
targetKind: string;
agentId: string;
boundAt: number;
accountId?: string;
channelId?: string;
};
ackMessage: {
id: string;
authorId?: string;
authorUsername?: string;
timestamp?: string;
content?: string;
};
};
type FailureResult = {
ok: false;
smokeId: string;
stage: "validation" | "send-message" | "wait-binding" | "wait-ack" | "discord-api" | "unexpected";
error: string;
diagnostics?: {
parentChannelRecent?: Array<{
id: string;
author?: string;
bot?: boolean;
content?: string;
}>;
bindingCandidates?: Array<{
threadId: string;
targetSessionKey: string;
targetKind?: string;
agentId?: string;
boundAt?: number;
}>;
};
};
const DISCORD_API_BASE = "https://discord.com/api/v10";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseNumber(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function resolveStateDir(): string {
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
if (override) {
return override.startsWith("~")
? path.resolve(process.env.HOME || "", override.slice(1))
: path.resolve(override);
}
const home = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || "";
return path.join(home, ".openclaw");
}
function resolveArg(flag: string): string | undefined {
const argv = process.argv.slice(2);
const eq = argv.find((entry) => entry.startsWith(`${flag}=`));
if (eq) {
return eq.slice(flag.length + 1);
}
const idx = argv.indexOf(flag);
if (idx >= 0 && idx + 1 < argv.length) {
return argv[idx + 1];
}
return undefined;
}
function hasFlag(flag: string): boolean {
return process.argv.slice(2).includes(flag);
}
function usage(): string {
return (
"Usage: bun scripts/dev/discord-acp-plain-language-smoke.ts " +
"--channel <discord-channel-id> [--token <driver-token> | --driver webhook --bot-token <bot-token>] [options]\n\n" +
"Manual live smoke only (not CI). Sends a plain-language instruction in Discord and verifies:\n" +
"1) OpenClaw spawned an ACP thread binding\n" +
"2) agent replied in that bound thread with the expected ACK token\n\n" +
"Options:\n" +
" --channel <id> Parent Discord channel id (required)\n" +
" --driver <token|webhook> Driver transport mode (default: token)\n" +
" --token <token> Driver Discord token (required for driver=token)\n" +
" --token-prefix <prefix> Auth prefix for --token (default: Bot)\n" +
" --bot-token <token> Bot token for webhook driver mode\n" +
" --bot-token-prefix <prefix> Auth prefix for --bot-token (default: Bot)\n" +
" --agent <id> Expected ACP agent id (default: codex)\n" +
" --mention <user-id> Mention this user in the instruction (optional)\n" +
" --instruction <text> Custom instruction template (optional)\n" +
" --timeout-ms <n> Total timeout in ms (default: 240000)\n" +
" --poll-ms <n> Poll interval in ms (default: 1500)\n" +
" --thread-bindings-path <p> Override thread-bindings json path\n" +
" --json Emit JSON output\n" +
"\n" +
"Environment fallbacks:\n" +
" OPENCLAW_DISCORD_SMOKE_CHANNEL_ID\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN\n" +
" OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX\n" +
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN\n" +
" OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX\n" +
" OPENCLAW_DISCORD_SMOKE_AGENT\n" +
" OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID\n" +
" OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS\n" +
" OPENCLAW_DISCORD_SMOKE_POLL_MS\n" +
" OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH"
);
}
function parseArgs(): Args {
const channelId =
resolveArg("--channel") ||
process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID ||
process.env.CLAWDBOT_DISCORD_SMOKE_CHANNEL_ID ||
"";
const driverModeRaw =
resolveArg("--driver") ||
process.env.OPENCLAW_DISCORD_SMOKE_DRIVER ||
process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER ||
"token";
const normalizedDriverMode = driverModeRaw.trim().toLowerCase();
const driverMode: DriverMode =
normalizedDriverMode === "webhook"
? "webhook"
: normalizedDriverMode === "token"
? "token"
: "token";
const driverToken =
resolveArg("--token") ||
process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN ||
process.env.CLAWDBOT_DISCORD_SMOKE_DRIVER_TOKEN ||
"";
const driverTokenPrefix =
resolveArg("--token-prefix") || process.env.OPENCLAW_DISCORD_SMOKE_DRIVER_TOKEN_PREFIX || "Bot";
const botToken =
resolveArg("--bot-token") ||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN ||
process.env.CLAWDBOT_DISCORD_SMOKE_BOT_TOKEN ||
process.env.DISCORD_BOT_TOKEN ||
"";
const botTokenPrefix =
resolveArg("--bot-token-prefix") ||
process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN_PREFIX ||
"Bot";
const targetAgent =
resolveArg("--agent") ||
process.env.OPENCLAW_DISCORD_SMOKE_AGENT ||
process.env.CLAWDBOT_DISCORD_SMOKE_AGENT ||
"codex";
const mentionUserId =
resolveArg("--mention") ||
process.env.OPENCLAW_DISCORD_SMOKE_MENTION_USER_ID ||
process.env.CLAWDBOT_DISCORD_SMOKE_MENTION_USER_ID ||
undefined;
const instruction =
resolveArg("--instruction") ||
process.env.OPENCLAW_DISCORD_SMOKE_INSTRUCTION ||
process.env.CLAWDBOT_DISCORD_SMOKE_INSTRUCTION ||
undefined;
const timeoutMs = parseNumber(
resolveArg("--timeout-ms") || process.env.OPENCLAW_DISCORD_SMOKE_TIMEOUT_MS,
240_000,
);
const pollMs = parseNumber(
resolveArg("--poll-ms") || process.env.OPENCLAW_DISCORD_SMOKE_POLL_MS,
1_500,
);
const defaultBindingsPath = path.join(resolveStateDir(), "discord", "thread-bindings.json");
const threadBindingsPath =
resolveArg("--thread-bindings-path") ||
process.env.OPENCLAW_DISCORD_SMOKE_THREAD_BINDINGS_PATH ||
defaultBindingsPath;
const json = hasFlag("--json");
if (!channelId) {
throw new Error(usage());
}
if (driverMode === "token" && !driverToken) {
throw new Error(usage());
}
if (driverMode === "webhook" && !botToken) {
throw new Error(usage());
}
return {
channelId,
driverMode,
driverToken,
driverTokenPrefix,
botToken,
botTokenPrefix,
targetAgent,
timeoutMs,
pollMs,
mentionUserId,
instruction,
threadBindingsPath,
json,
};
}
function resolveAuthorizationHeader(params: { token: string; tokenPrefix: string }): string {
const token = params.token.trim();
if (!token) {
throw new Error("Missing Discord driver token.");
}
if (token.includes(" ")) {
return token;
}
return `${params.tokenPrefix.trim() || "Bot"} ${token}`;
}
async function discordApi<T>(params: {
method: "GET" | "POST";
path: string;
authHeader: string;
body?: unknown;
retries?: number;
}): Promise<T> {
const retries = params.retries ?? 6;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const response = await fetch(`${DISCORD_API_BASE}${params.path}`, {
method: params.method,
headers: {
Authorization: params.authHeader,
"Content-Type": "application/json",
},
body: params.body === undefined ? undefined : JSON.stringify(params.body),
});
if (response.status === 429) {
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
await sleep(Math.ceil(waitSeconds * 1000));
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Discord API ${params.method} ${params.path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
throw new Error(`Discord API ${params.method} ${params.path} exceeded retry budget.`);
}
async function discordWebhookApi<T>(params: {
method: "POST" | "DELETE";
webhookId: string;
webhookToken: string;
body?: unknown;
query?: string;
retries?: number;
}): Promise<T> {
const retries = params.retries ?? 6;
const suffix = params.query ? `?${params.query}` : "";
const path = `/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}${suffix}`;
for (let attempt = 0; attempt <= retries; attempt += 1) {
const response = await fetch(`${DISCORD_API_BASE}${path}`, {
method: params.method,
headers: {
"Content-Type": "application/json",
},
body: params.body === undefined ? undefined : JSON.stringify(params.body),
});
if (response.status === 429) {
const body = (await response.json().catch(() => ({}))) as { retry_after?: number };
const waitSeconds = typeof body.retry_after === "number" ? body.retry_after : 1;
await sleep(Math.ceil(waitSeconds * 1000));
continue;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Discord webhook API ${params.method} ${path} failed: ${response.status} ${response.statusText}${text ? ` :: ${text}` : ""}`,
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
throw new Error(`Discord webhook API ${params.method} ${path} exceeded retry budget.`);
}
async function readThreadBindings(filePath: string): Promise<ThreadBindingRecord[]> {
const raw = await fs.readFile(filePath, "utf8");
const payload = JSON.parse(raw) as ThreadBindingsPayload;
const entries = Object.values(payload.bindings ?? {});
return entries.filter((entry) => Boolean(entry?.threadId && entry?.targetSessionKey));
}
function normalizeBoundAt(record: ThreadBindingRecord): number {
if (typeof record.boundAt === "number" && Number.isFinite(record.boundAt)) {
return record.boundAt;
}
return 0;
}
function resolveCandidateBindings(params: {
entries: ThreadBindingRecord[];
minBoundAt: number;
targetAgent: string;
}): ThreadBindingRecord[] {
const normalizedTargetAgent = params.targetAgent.trim().toLowerCase();
return params.entries
.filter((entry) => {
const targetKind = String(entry.targetKind || "")
.trim()
.toLowerCase();
if (targetKind !== "acp") {
return false;
}
if (normalizeBoundAt(entry) < params.minBoundAt) {
return false;
}
const agentId = String(entry.agentId || "")
.trim()
.toLowerCase();
if (normalizedTargetAgent && agentId && agentId !== normalizedTargetAgent) {
return false;
}
return true;
})
.toSorted((a, b) => normalizeBoundAt(b) - normalizeBoundAt(a));
}
function buildInstruction(params: {
smokeId: string;
ackToken: string;
targetAgent: string;
mentionUserId?: string;
template?: string;
}): string {
const mentionPrefix = params.mentionUserId?.trim() ? `<@${params.mentionUserId.trim()}> ` : "";
if (params.template?.trim()) {
return mentionPrefix + params.template.trim();
}
return (
mentionPrefix +
`Manual smoke ${params.smokeId}: Please spawn a ${params.targetAgent} ACP coding agent in a thread for this request, keep it persistent, and in that thread reply with exactly "${params.ackToken}" and nothing else.`
);
}
function toRecentMessageRow(message: DiscordMessage) {
return {
id: message.id,
author: message.author?.username || message.author?.id || "unknown",
bot: Boolean(message.author?.bot),
content: (message.content || "").slice(0, 500),
};
}
function printOutput(params: { json: boolean; payload: SuccessResult | FailureResult }) {
if (params.json) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(params.payload, null, 2));
return;
}
if (params.payload.ok) {
const success = params.payload;
// eslint-disable-next-line no-console
console.log("PASS");
// eslint-disable-next-line no-console
console.log(`smokeId: ${success.smokeId}`);
// eslint-disable-next-line no-console
console.log(`sentMessageId: ${success.sentMessageId}`);
// eslint-disable-next-line no-console
console.log(`threadId: ${success.binding.threadId}`);
// eslint-disable-next-line no-console
console.log(`sessionKey: ${success.binding.targetSessionKey}`);
// eslint-disable-next-line no-console
console.log(`ackMessageId: ${success.ackMessage.id}`);
// eslint-disable-next-line no-console
console.log(
`ackAuthor: ${success.ackMessage.authorUsername || success.ackMessage.authorId || "unknown"}`,
);
return;
}
const failure = params.payload;
// eslint-disable-next-line no-console
console.error("FAIL");
// eslint-disable-next-line no-console
console.error(`stage: ${failure.stage}`);
// eslint-disable-next-line no-console
console.error(`smokeId: ${failure.smokeId}`);
// eslint-disable-next-line no-console
console.error(`error: ${failure.error}`);
if (failure.diagnostics?.bindingCandidates?.length) {
// eslint-disable-next-line no-console
console.error("binding candidates:");
for (const candidate of failure.diagnostics.bindingCandidates) {
// eslint-disable-next-line no-console
console.error(
` thread=${candidate.threadId} kind=${candidate.targetKind || "?"} agent=${candidate.agentId || "?"} boundAt=${candidate.boundAt || 0} session=${candidate.targetSessionKey}`,
);
}
}
if (failure.diagnostics?.parentChannelRecent?.length) {
// eslint-disable-next-line no-console
console.error("recent parent channel messages:");
for (const row of failure.diagnostics.parentChannelRecent) {
// eslint-disable-next-line no-console
console.error(` ${row.id} ${row.author}${row.bot ? " [bot]" : ""}: ${row.content || ""}`);
}
}
}
async function run(): Promise<SuccessResult | FailureResult> {
let args: Args;
try {
args = parseArgs();
} catch (err) {
return {
ok: false,
stage: "validation",
smokeId: "n/a",
error: err instanceof Error ? err.message : String(err),
};
}
const smokeId = `acp-smoke-${Date.now()}-${randomUUID().slice(0, 8)}`;
const ackToken = `ACP_SMOKE_ACK_${smokeId}`;
const instruction = buildInstruction({
smokeId,
ackToken,
targetAgent: args.targetAgent,
mentionUserId: args.mentionUserId,
template: args.instruction,
});
let readAuthHeader = "";
let sentMessageId = "";
let setupStage: "discord-api" | "send-message" = "discord-api";
let senderAuthorId: string | undefined;
let webhookForCleanup:
| {
id: string;
token: string;
}
| undefined;
try {
if (args.driverMode === "token") {
const authHeader = resolveAuthorizationHeader({
token: args.driverToken,
tokenPrefix: args.driverTokenPrefix,
});
readAuthHeader = authHeader;
const driverUser = await discordApi<DiscordUser>({
method: "GET",
path: "/users/@me",
authHeader,
});
senderAuthorId = driverUser.id;
setupStage = "send-message";
const sent = await discordApi<DiscordMessage>({
method: "POST",
path: `/channels/${encodeURIComponent(args.channelId)}/messages`,
authHeader,
body: {
content: instruction,
allowed_mentions: args.mentionUserId
? { parse: [], users: [args.mentionUserId] }
: { parse: [] },
},
});
sentMessageId = sent.id;
} else {
const botAuthHeader = resolveAuthorizationHeader({
token: args.botToken,
tokenPrefix: args.botTokenPrefix,
});
readAuthHeader = botAuthHeader;
await discordApi<DiscordUser>({
method: "GET",
path: "/users/@me",
authHeader: botAuthHeader,
});
setupStage = "send-message";
const webhook = await discordApi<{ id: string; token?: string | null }>({
method: "POST",
path: `/channels/${encodeURIComponent(args.channelId)}/webhooks`,
authHeader: botAuthHeader,
body: {
name: `openclaw-acp-smoke-${smokeId.slice(-8)}`,
},
});
if (!webhook.id || !webhook.token) {
return {
ok: false,
stage: "send-message",
smokeId,
error:
"Discord webhook creation succeeded but no webhook token was returned; cannot post smoke message.",
};
}
webhookForCleanup = { id: webhook.id, token: webhook.token };
const sent = await discordWebhookApi<DiscordMessage>({
method: "POST",
webhookId: webhook.id,
webhookToken: webhook.token,
query: "wait=true",
body: {
content: instruction,
allowed_mentions: args.mentionUserId
? { parse: [], users: [args.mentionUserId] }
: { parse: [] },
},
});
sentMessageId = sent.id;
senderAuthorId = sent.author?.id;
}
} catch (err) {
return {
ok: false,
stage: setupStage,
smokeId,
error: err instanceof Error ? err.message : String(err),
};
}
const startedAt = Date.now();
const deadline = startedAt + args.timeoutMs;
let winningBinding: ThreadBindingRecord | undefined;
let latestCandidates: ThreadBindingRecord[] = [];
try {
while (Date.now() < deadline && !winningBinding) {
try {
const entries = await readThreadBindings(args.threadBindingsPath);
latestCandidates = resolveCandidateBindings({
entries,
minBoundAt: startedAt - 3_000,
targetAgent: args.targetAgent,
});
winningBinding = latestCandidates[0];
} catch {
// Keep polling; file may not exist yet or may be mid-write.
}
if (!winningBinding) {
await sleep(args.pollMs);
}
}
if (!winningBinding?.threadId || !winningBinding?.targetSessionKey) {
let parentRecent: DiscordMessage[] = [];
try {
parentRecent = await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
authHeader: readAuthHeader,
});
} catch {
// Best effort diagnostics only.
}
return {
ok: false,
stage: "wait-binding",
smokeId,
error: `Timed out waiting for new ACP thread binding (path: ${args.threadBindingsPath}).`,
diagnostics: {
bindingCandidates: latestCandidates.slice(0, 6).map((entry) => ({
threadId: entry.threadId || "",
targetSessionKey: entry.targetSessionKey || "",
targetKind: entry.targetKind,
agentId: entry.agentId,
boundAt: entry.boundAt,
})),
parentChannelRecent: parentRecent.map(toRecentMessageRow),
},
};
}
const threadId = winningBinding.threadId;
let ackMessage: DiscordMessage | undefined;
while (Date.now() < deadline && !ackMessage) {
try {
const threadMessages = await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(threadId)}/messages?limit=50`,
authHeader: readAuthHeader,
});
ackMessage = threadMessages.find((message) => {
const content = message.content || "";
if (!content.includes(ackToken)) {
return false;
}
const authorId = message.author?.id || "";
return !senderAuthorId || authorId !== senderAuthorId;
});
} catch {
// Keep polling; thread can appear before read permissions settle.
}
if (!ackMessage) {
await sleep(args.pollMs);
}
}
if (!ackMessage) {
let parentRecent: DiscordMessage[] = [];
try {
parentRecent = await discordApi<DiscordMessage[]>({
method: "GET",
path: `/channels/${encodeURIComponent(args.channelId)}/messages?limit=20`,
authHeader: readAuthHeader,
});
} catch {
// Best effort diagnostics only.
}
return {
ok: false,
stage: "wait-ack",
smokeId,
error: `Thread bound (${threadId}) but timed out waiting for ACK token "${ackToken}" from OpenClaw.`,
diagnostics: {
bindingCandidates: [
{
threadId: winningBinding.threadId || "",
targetSessionKey: winningBinding.targetSessionKey || "",
targetKind: winningBinding.targetKind,
agentId: winningBinding.agentId,
boundAt: winningBinding.boundAt,
},
],
parentChannelRecent: parentRecent.map(toRecentMessageRow),
},
};
}
return {
ok: true,
smokeId,
ackToken,
sentMessageId,
binding: {
threadId,
targetSessionKey: winningBinding.targetSessionKey,
targetKind: String(winningBinding.targetKind || "acp"),
agentId: String(winningBinding.agentId || args.targetAgent),
boundAt: normalizeBoundAt(winningBinding),
accountId: winningBinding.accountId,
channelId: winningBinding.channelId,
},
ackMessage: {
id: ackMessage.id,
authorId: ackMessage.author?.id,
authorUsername: ackMessage.author?.username,
timestamp: ackMessage.timestamp,
content: ackMessage.content,
},
};
} finally {
if (webhookForCleanup) {
await discordWebhookApi<void>({
method: "DELETE",
webhookId: webhookForCleanup.id,
webhookToken: webhookForCleanup.token,
}).catch(() => {
// Best-effort cleanup only.
});
}
}
}
if (hasFlag("--help") || hasFlag("-h")) {
// eslint-disable-next-line no-console
console.log(usage());
process.exit(0);
}
const result = await run().catch(
(err): FailureResult => ({
ok: false,
stage: "unexpected",
smokeId: "n/a",
error: err instanceof Error ? err.message : String(err),
}),
);
printOutput({
json: hasFlag("--json"),
payload: result,
});
process.exit(result.ok ? 0 : 1);

View File

@@ -1,6 +1,6 @@
---
name: coding-agent
description: "Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true."
description: 'Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Requires a bash tool that supports pty:true.'
metadata:
{
"openclaw": { "emoji": "🧩", "requires": { "anyBins": ["claude", "codex", "opencode", "pi"] } },

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
import type { OpenClawConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { withAcpRuntimeErrorBoundary } from "../runtime/errors.js";
import {
createIdentityFromStatus,
identityEquals,
mergeSessionIdentity,
resolveRuntimeHandleIdentifiersFromIdentity,
resolveSessionIdentityFromMeta,
} from "../runtime/session-identity.js";
import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeStatus } from "../runtime/types.js";
import type { SessionAcpMeta, SessionEntry } from "./manager.types.js";
import { hasLegacyAcpIdentityProjection } from "./manager.utils.js";
export async function reconcileManagerRuntimeSessionIdentifiers(params: {
cfg: OpenClawConfig;
sessionKey: string;
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
runtimeStatus?: AcpRuntimeStatus;
failOnStatusError: boolean;
setCachedHandle: (sessionKey: string, handle: AcpRuntimeHandle) => void;
writeSessionMeta: (params: {
cfg: OpenClawConfig;
sessionKey: string;
mutate: (
current: SessionAcpMeta | undefined,
entry: SessionEntry | undefined,
) => SessionAcpMeta | null | undefined;
failOnError?: boolean;
}) => Promise<SessionEntry | null>;
}): Promise<{
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
runtimeStatus?: AcpRuntimeStatus;
}> {
let runtimeStatus = params.runtimeStatus;
if (!runtimeStatus && params.runtime.getStatus) {
try {
runtimeStatus = await withAcpRuntimeErrorBoundary({
run: async () =>
await params.runtime.getStatus!({
handle: params.handle,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP runtime status.",
});
} catch (error) {
if (params.failOnStatusError) {
throw error;
}
logVerbose(
`acp-manager: failed to refresh ACP runtime status for ${params.sessionKey}: ${String(error)}`,
);
return {
handle: params.handle,
meta: params.meta,
runtimeStatus,
};
}
}
const now = Date.now();
const currentIdentity = resolveSessionIdentityFromMeta(params.meta);
const nextIdentity =
mergeSessionIdentity({
current: currentIdentity,
incoming: createIdentityFromStatus({
status: runtimeStatus,
now,
}),
now,
}) ?? currentIdentity;
const handleIdentifiers = resolveRuntimeHandleIdentifiersFromIdentity(nextIdentity);
const handleChanged =
handleIdentifiers.backendSessionId !== params.handle.backendSessionId ||
handleIdentifiers.agentSessionId !== params.handle.agentSessionId;
const nextHandle: AcpRuntimeHandle = handleChanged
? {
...params.handle,
...(handleIdentifiers.backendSessionId
? { backendSessionId: handleIdentifiers.backendSessionId }
: {}),
...(handleIdentifiers.agentSessionId
? { agentSessionId: handleIdentifiers.agentSessionId }
: {}),
}
: params.handle;
if (handleChanged) {
params.setCachedHandle(params.sessionKey, nextHandle);
}
const metaChanged =
!identityEquals(currentIdentity, nextIdentity) || hasLegacyAcpIdentityProjection(params.meta);
if (!metaChanged) {
return {
handle: nextHandle,
meta: params.meta,
runtimeStatus,
};
}
const nextMeta: SessionAcpMeta = {
backend: params.meta.backend,
agent: params.meta.agent,
runtimeSessionName: params.meta.runtimeSessionName,
...(nextIdentity ? { identity: nextIdentity } : {}),
mode: params.meta.mode,
...(params.meta.runtimeOptions ? { runtimeOptions: params.meta.runtimeOptions } : {}),
...(params.meta.cwd ? { cwd: params.meta.cwd } : {}),
lastActivityAt: now,
state: params.meta.state,
...(params.meta.lastError ? { lastError: params.meta.lastError } : {}),
};
if (!identityEquals(currentIdentity, nextIdentity)) {
const currentAgentSessionId = currentIdentity?.agentSessionId ?? "<none>";
const nextAgentSessionId = nextIdentity?.agentSessionId ?? "<none>";
const currentAcpxSessionId = currentIdentity?.acpxSessionId ?? "<none>";
const nextAcpxSessionId = nextIdentity?.acpxSessionId ?? "<none>";
const currentAcpxRecordId = currentIdentity?.acpxRecordId ?? "<none>";
const nextAcpxRecordId = nextIdentity?.acpxRecordId ?? "<none>";
logVerbose(
`acp-manager: session identity updated for ${params.sessionKey} ` +
`(agentSessionId ${currentAgentSessionId} -> ${nextAgentSessionId}, ` +
`acpxSessionId ${currentAcpxSessionId} -> ${nextAcpxSessionId}, ` +
`acpxRecordId ${currentAcpxRecordId} -> ${nextAcpxRecordId})`,
);
}
await params.writeSessionMeta({
cfg: params.cfg,
sessionKey: params.sessionKey,
mutate: (current, entry) => {
if (!entry) {
return null;
}
const base = current ?? entry.acp;
if (!base) {
return null;
}
return {
backend: base.backend,
agent: base.agent,
runtimeSessionName: base.runtimeSessionName,
...(nextIdentity ? { identity: nextIdentity } : {}),
mode: base.mode,
...(base.runtimeOptions ? { runtimeOptions: base.runtimeOptions } : {}),
...(base.cwd ? { cwd: base.cwd } : {}),
state: base.state,
lastActivityAt: now,
...(base.lastError ? { lastError: base.lastError } : {}),
};
},
});
return {
handle: nextHandle,
meta: nextMeta,
runtimeStatus,
};
}

View File

@@ -0,0 +1,118 @@
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "../runtime/errors.js";
import type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeHandle } from "../runtime/types.js";
import type { SessionAcpMeta } from "./manager.types.js";
import { createUnsupportedControlError } from "./manager.utils.js";
import type { CachedRuntimeState } from "./runtime-cache.js";
import {
buildRuntimeConfigOptionPairs,
buildRuntimeControlSignature,
normalizeText,
resolveRuntimeOptionsFromMeta,
} from "./runtime-options.js";
export async function resolveManagerRuntimeCapabilities(params: {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
}): Promise<AcpRuntimeCapabilities> {
let reported: AcpRuntimeCapabilities | undefined;
if (params.runtime.getCapabilities) {
reported = await withAcpRuntimeErrorBoundary({
run: async () => await params.runtime.getCapabilities!({ handle: params.handle }),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP runtime capabilities.",
});
}
const controls = new Set<AcpRuntimeCapabilities["controls"][number]>(reported?.controls ?? []);
if (params.runtime.setMode) {
controls.add("session/set_mode");
}
if (params.runtime.setConfigOption) {
controls.add("session/set_config_option");
}
if (params.runtime.getStatus) {
controls.add("session/status");
}
const normalizedKeys = (reported?.configOptionKeys ?? [])
.map((entry) => normalizeText(entry))
.filter(Boolean) as string[];
return {
controls: [...controls].toSorted(),
...(normalizedKeys.length > 0 ? { configOptionKeys: normalizedKeys } : {}),
};
}
export async function applyManagerRuntimeControls(params: {
sessionKey: string;
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
getCachedRuntimeState: (sessionKey: string) => CachedRuntimeState | null;
}): Promise<void> {
const options = resolveRuntimeOptionsFromMeta(params.meta);
const signature = buildRuntimeControlSignature(options);
const cached = params.getCachedRuntimeState(params.sessionKey);
if (cached?.appliedControlSignature === signature) {
return;
}
const capabilities = await resolveManagerRuntimeCapabilities({
runtime: params.runtime,
handle: params.handle,
});
const backend = params.handle.backend || params.meta.backend;
const runtimeMode = normalizeText(options.runtimeMode);
const configOptions = buildRuntimeConfigOptionPairs(options);
const advertisedKeys = new Set(
(capabilities.configOptionKeys ?? [])
.map((entry) => normalizeText(entry))
.filter(Boolean) as string[],
);
await withAcpRuntimeErrorBoundary({
run: async () => {
if (runtimeMode) {
if (!capabilities.controls.includes("session/set_mode") || !params.runtime.setMode) {
throw createUnsupportedControlError({
backend,
control: "session/set_mode",
});
}
await params.runtime.setMode({
handle: params.handle,
mode: runtimeMode,
});
}
if (configOptions.length > 0) {
if (
!capabilities.controls.includes("session/set_config_option") ||
!params.runtime.setConfigOption
) {
throw createUnsupportedControlError({
backend,
control: "session/set_config_option",
});
}
for (const [key, value] of configOptions) {
if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) {
throw new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
`ACP backend "${backend}" does not accept config key "${key}".`,
);
}
await params.runtime.setConfigOption({
handle: params.handle,
key,
value,
});
}
}
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not apply ACP runtime options before turn execution.",
});
if (cached) {
cached.appliedControlSignature = signature;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import { AcpSessionManager } from "./manager.core.js";
export { AcpSessionManager } from "./manager.core.js";
export type {
AcpCloseSessionInput,
AcpCloseSessionResult,
AcpInitializeSessionInput,
AcpManagerObservabilitySnapshot,
AcpRunTurnInput,
AcpSessionResolution,
AcpSessionRuntimeOptions,
AcpSessionStatus,
AcpStartupIdentityReconcileResult,
} from "./manager.types.js";
let ACP_SESSION_MANAGER_SINGLETON: AcpSessionManager | null = null;
export function getAcpSessionManager(): AcpSessionManager {
if (!ACP_SESSION_MANAGER_SINGLETON) {
ACP_SESSION_MANAGER_SINGLETON = new AcpSessionManager();
}
return ACP_SESSION_MANAGER_SINGLETON;
}
export const __testing = {
resetAcpSessionManagerForTests() {
ACP_SESSION_MANAGER_SINGLETON = null;
},
};

View File

@@ -0,0 +1,141 @@
import type { OpenClawConfig } from "../../config/config.js";
import type {
SessionAcpIdentity,
AcpSessionRuntimeOptions,
SessionAcpMeta,
SessionEntry,
} from "../../config/sessions/types.js";
import type { AcpRuntimeError } from "../runtime/errors.js";
import { requireAcpRuntimeBackend } from "../runtime/registry.js";
import {
listAcpSessionEntries,
readAcpSessionEntry,
upsertAcpSessionMeta,
} from "../runtime/session-meta.js";
import type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimePromptMode,
AcpRuntimeSessionMode,
AcpRuntimeStatus,
} from "../runtime/types.js";
export type AcpSessionResolution =
| {
kind: "none";
sessionKey: string;
}
| {
kind: "stale";
sessionKey: string;
error: AcpRuntimeError;
}
| {
kind: "ready";
sessionKey: string;
meta: SessionAcpMeta;
};
export type AcpInitializeSessionInput = {
cfg: OpenClawConfig;
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
backendId?: string;
};
export type AcpRunTurnInput = {
cfg: OpenClawConfig;
sessionKey: string;
text: string;
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;
onEvent?: (event: AcpRuntimeEvent) => Promise<void> | void;
};
export type AcpCloseSessionInput = {
cfg: OpenClawConfig;
sessionKey: string;
reason: string;
clearMeta?: boolean;
allowBackendUnavailable?: boolean;
requireAcpSession?: boolean;
};
export type AcpCloseSessionResult = {
runtimeClosed: boolean;
runtimeNotice?: string;
metaCleared: boolean;
};
export type AcpSessionStatus = {
sessionKey: string;
backend: string;
agent: string;
identity?: SessionAcpIdentity;
state: SessionAcpMeta["state"];
mode: AcpRuntimeSessionMode;
runtimeOptions: AcpSessionRuntimeOptions;
capabilities: AcpRuntimeCapabilities;
runtimeStatus?: AcpRuntimeStatus;
lastActivityAt: number;
lastError?: string;
};
export type AcpManagerObservabilitySnapshot = {
runtimeCache: {
activeSessions: number;
idleTtlMs: number;
evictedTotal: number;
lastEvictedAt?: number;
};
turns: {
active: number;
queueDepth: number;
completed: number;
failed: number;
averageLatencyMs: number;
maxLatencyMs: number;
};
errorsByCode: Record<string, number>;
};
export type AcpStartupIdentityReconcileResult = {
checked: number;
resolved: number;
failed: number;
};
export type ActiveTurnState = {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
abortController: AbortController;
cancelPromise?: Promise<void>;
};
export type TurnLatencyStats = {
completed: number;
failed: number;
totalMs: number;
maxMs: number;
};
export type AcpSessionManagerDeps = {
listAcpSessions: typeof listAcpSessionEntries;
readSessionEntry: typeof readAcpSessionEntry;
upsertSessionMeta: typeof upsertAcpSessionMeta;
requireRuntimeBackend: typeof requireAcpRuntimeBackend;
};
export const DEFAULT_DEPS: AcpSessionManagerDeps = {
listAcpSessions: listAcpSessionEntries,
readSessionEntry: readAcpSessionEntry,
upsertSessionMeta: upsertAcpSessionMeta,
requireRuntimeBackend: requireAcpRuntimeBackend,
};
export type { AcpSessionRuntimeOptions, SessionAcpMeta, SessionEntry };

View File

@@ -0,0 +1,64 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionAcpMeta } from "../../config/sessions/types.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? fallback);
}
export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError {
return new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP metadata is missing for ${sessionKey}. Recreate this ACP session with /acp spawn and rebind the thread.`,
);
}
export function normalizeSessionKey(sessionKey: string): string {
return sessionKey.trim();
}
export function normalizeActorKey(sessionKey: string): string {
return sessionKey.trim().toLowerCase();
}
export function normalizeAcpErrorCode(code: string | undefined): AcpRuntimeError["code"] {
if (!code) {
return "ACP_TURN_FAILED";
}
const normalized = code.trim().toUpperCase();
for (const allowed of ACP_ERROR_CODES) {
if (allowed === normalized) {
return allowed;
}
}
return "ACP_TURN_FAILED";
}
export function createUnsupportedControlError(params: {
backend: string;
control: string;
}): AcpRuntimeError {
return new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
`ACP backend "${params.backend}" does not support ${params.control}.`,
);
}
export function resolveRuntimeIdleTtlMs(cfg: OpenClawConfig): number {
const ttlMinutes = cfg.acp?.runtime?.ttlMinutes;
if (typeof ttlMinutes !== "number" || !Number.isFinite(ttlMinutes) || ttlMinutes <= 0) {
return 0;
}
return Math.round(ttlMinutes * 60 * 1000);
}
export function hasLegacyAcpIdentityProjection(meta: SessionAcpMeta): boolean {
const raw = meta as Record<string, unknown>;
return (
Object.hasOwn(raw, "backendSessionId") ||
Object.hasOwn(raw, "agentSessionId") ||
Object.hasOwn(raw, "sessionIdsProvisional")
);
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import type { AcpRuntime } from "../runtime/types.js";
import type { AcpRuntimeHandle } from "../runtime/types.js";
import type { CachedRuntimeState } from "./runtime-cache.js";
import { RuntimeCache } from "./runtime-cache.js";
function mockState(sessionKey: string): CachedRuntimeState {
const runtime = {
ensureSession: vi.fn(async () => ({
sessionKey,
backend: "acpx",
runtimeSessionName: `runtime:${sessionKey}`,
})),
runTurn: vi.fn(async function* () {
yield { type: "done" as const };
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
} as unknown as AcpRuntime;
return {
runtime,
handle: {
sessionKey,
backend: "acpx",
runtimeSessionName: `runtime:${sessionKey}`,
} as AcpRuntimeHandle,
backend: "acpx",
agent: "codex",
mode: "persistent",
};
}
describe("RuntimeCache", () => {
it("tracks idle candidates with touch-aware lookups", () => {
vi.useFakeTimers();
try {
const cache = new RuntimeCache();
const actor = "agent:codex:acp:s1";
cache.set(actor, mockState(actor), { now: 1_000 });
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 1_999 })).toHaveLength(0);
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 2_000 })).toHaveLength(1);
cache.get(actor, { now: 2_500 });
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_200 })).toHaveLength(0);
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_500 })).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("returns snapshot entries with idle durations", () => {
const cache = new RuntimeCache();
cache.set("a", mockState("a"), { now: 10 });
cache.set("b", mockState("b"), { now: 100 });
const snapshot = cache.snapshot({ now: 1_100 });
const byActor = new Map(snapshot.map((entry) => [entry.actorKey, entry]));
expect(byActor.get("a")?.idleMs).toBe(1_090);
expect(byActor.get("b")?.idleMs).toBe(1_000);
});
});

View File

@@ -0,0 +1,99 @@
import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeSessionMode } from "../runtime/types.js";
export type CachedRuntimeState = {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
backend: string;
agent: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
appliedControlSignature?: string;
};
type RuntimeCacheEntry = {
state: CachedRuntimeState;
lastTouchedAt: number;
};
export type CachedRuntimeSnapshot = {
actorKey: string;
state: CachedRuntimeState;
lastTouchedAt: number;
idleMs: number;
};
export class RuntimeCache {
private readonly cache = new Map<string, RuntimeCacheEntry>();
size(): number {
return this.cache.size;
}
has(actorKey: string): boolean {
return this.cache.has(actorKey);
}
get(
actorKey: string,
params: {
touch?: boolean;
now?: number;
} = {},
): CachedRuntimeState | null {
const entry = this.cache.get(actorKey);
if (!entry) {
return null;
}
if (params.touch !== false) {
entry.lastTouchedAt = params.now ?? Date.now();
}
return entry.state;
}
peek(actorKey: string): CachedRuntimeState | null {
return this.get(actorKey, { touch: false });
}
getLastTouchedAt(actorKey: string): number | null {
return this.cache.get(actorKey)?.lastTouchedAt ?? null;
}
set(
actorKey: string,
state: CachedRuntimeState,
params: {
now?: number;
} = {},
): void {
this.cache.set(actorKey, {
state,
lastTouchedAt: params.now ?? Date.now(),
});
}
clear(actorKey: string): void {
this.cache.delete(actorKey);
}
snapshot(params: { now?: number } = {}): CachedRuntimeSnapshot[] {
const now = params.now ?? Date.now();
const entries: CachedRuntimeSnapshot[] = [];
for (const [actorKey, entry] of this.cache.entries()) {
entries.push({
actorKey,
state: entry.state,
lastTouchedAt: entry.lastTouchedAt,
idleMs: Math.max(0, now - entry.lastTouchedAt),
});
}
return entries;
}
collectIdleCandidates(params: { maxIdleMs: number; now?: number }): CachedRuntimeSnapshot[] {
if (!Number.isFinite(params.maxIdleMs) || params.maxIdleMs <= 0) {
return [];
}
const now = params.now ?? Date.now();
return this.snapshot({ now }).filter((entry) => entry.idleMs >= params.maxIdleMs);
}
}

View File

@@ -0,0 +1,349 @@
import { isAbsolute } from "node:path";
import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js";
import { AcpRuntimeError } from "../runtime/errors.js";
const MAX_RUNTIME_MODE_LENGTH = 64;
const MAX_MODEL_LENGTH = 200;
const MAX_PERMISSION_PROFILE_LENGTH = 80;
const MAX_CWD_LENGTH = 4096;
const MIN_TIMEOUT_SECONDS = 1;
const MAX_TIMEOUT_SECONDS = 24 * 60 * 60;
const MAX_BACKEND_OPTION_KEY_LENGTH = 64;
const MAX_BACKEND_OPTION_VALUE_LENGTH = 512;
const MAX_BACKEND_EXTRAS = 32;
const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i;
function failInvalidOption(message: string): never {
throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message);
}
function validateNoControlChars(value: string, field: string): string {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code < 32 || code === 127) {
failInvalidOption(`${field} must not include control characters.`);
}
}
return value;
}
function validateBoundedText(params: { value: unknown; field: string; maxLength: number }): string {
const normalized = normalizeText(params.value);
if (!normalized) {
failInvalidOption(`${params.field} must not be empty.`);
}
if (normalized.length > params.maxLength) {
failInvalidOption(`${params.field} must be at most ${params.maxLength} characters.`);
}
return validateNoControlChars(normalized, params.field);
}
function validateBackendOptionKey(rawKey: unknown): string {
const key = validateBoundedText({
value: rawKey,
field: "ACP config key",
maxLength: MAX_BACKEND_OPTION_KEY_LENGTH,
});
if (!SAFE_OPTION_KEY_RE.test(key)) {
failInvalidOption(
"ACP config key must use letters, numbers, dots, colons, underscores, or dashes.",
);
}
return key;
}
function validateBackendOptionValue(rawValue: unknown): string {
return validateBoundedText({
value: rawValue,
field: "ACP config value",
maxLength: MAX_BACKEND_OPTION_VALUE_LENGTH,
});
}
export function validateRuntimeModeInput(rawMode: unknown): string {
return validateBoundedText({
value: rawMode,
field: "Runtime mode",
maxLength: MAX_RUNTIME_MODE_LENGTH,
});
}
export function validateRuntimeModelInput(rawModel: unknown): string {
return validateBoundedText({
value: rawModel,
field: "Model id",
maxLength: MAX_MODEL_LENGTH,
});
}
export function validateRuntimePermissionProfileInput(rawProfile: unknown): string {
return validateBoundedText({
value: rawProfile,
field: "Permission profile",
maxLength: MAX_PERMISSION_PROFILE_LENGTH,
});
}
export function validateRuntimeCwdInput(rawCwd: unknown): string {
const cwd = validateBoundedText({
value: rawCwd,
field: "Working directory",
maxLength: MAX_CWD_LENGTH,
});
if (!isAbsolute(cwd)) {
failInvalidOption(`Working directory must be an absolute path. Received "${cwd}".`);
}
return cwd;
}
export function validateRuntimeTimeoutSecondsInput(rawTimeout: unknown): number {
if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) {
failInvalidOption("Timeout must be a positive integer in seconds.");
}
const timeout = Math.round(rawTimeout);
if (timeout < MIN_TIMEOUT_SECONDS || timeout > MAX_TIMEOUT_SECONDS) {
failInvalidOption(
`Timeout must be between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS} seconds.`,
);
}
return timeout;
}
export function parseRuntimeTimeoutSecondsInput(rawTimeout: unknown): number {
const normalized = normalizeText(rawTimeout);
if (!normalized || !/^\d+$/.test(normalized)) {
failInvalidOption("Timeout must be a positive integer in seconds.");
}
return validateRuntimeTimeoutSecondsInput(Number.parseInt(normalized, 10));
}
export function validateRuntimeConfigOptionInput(
rawKey: unknown,
rawValue: unknown,
): {
key: string;
value: string;
} {
return {
key: validateBackendOptionKey(rawKey),
value: validateBackendOptionValue(rawValue),
};
}
export function validateRuntimeOptionPatch(
patch: Partial<AcpSessionRuntimeOptions> | undefined,
): Partial<AcpSessionRuntimeOptions> {
if (!patch) {
return {};
}
const rawPatch = patch as Record<string, unknown>;
const allowedKeys = new Set([
"runtimeMode",
"model",
"cwd",
"permissionProfile",
"timeoutSeconds",
"backendExtras",
]);
for (const key of Object.keys(rawPatch)) {
if (!allowedKeys.has(key)) {
failInvalidOption(`Unknown runtime option "${key}".`);
}
}
const next: Partial<AcpSessionRuntimeOptions> = {};
if (Object.hasOwn(rawPatch, "runtimeMode")) {
if (rawPatch.runtimeMode === undefined) {
next.runtimeMode = undefined;
} else {
next.runtimeMode = validateRuntimeModeInput(rawPatch.runtimeMode);
}
}
if (Object.hasOwn(rawPatch, "model")) {
if (rawPatch.model === undefined) {
next.model = undefined;
} else {
next.model = validateRuntimeModelInput(rawPatch.model);
}
}
if (Object.hasOwn(rawPatch, "cwd")) {
if (rawPatch.cwd === undefined) {
next.cwd = undefined;
} else {
next.cwd = validateRuntimeCwdInput(rawPatch.cwd);
}
}
if (Object.hasOwn(rawPatch, "permissionProfile")) {
if (rawPatch.permissionProfile === undefined) {
next.permissionProfile = undefined;
} else {
next.permissionProfile = validateRuntimePermissionProfileInput(rawPatch.permissionProfile);
}
}
if (Object.hasOwn(rawPatch, "timeoutSeconds")) {
if (rawPatch.timeoutSeconds === undefined) {
next.timeoutSeconds = undefined;
} else {
next.timeoutSeconds = validateRuntimeTimeoutSecondsInput(rawPatch.timeoutSeconds);
}
}
if (Object.hasOwn(rawPatch, "backendExtras")) {
const rawExtras = rawPatch.backendExtras;
if (rawExtras === undefined) {
next.backendExtras = undefined;
} else if (!rawExtras || typeof rawExtras !== "object" || Array.isArray(rawExtras)) {
failInvalidOption("Backend extras must be a key/value object.");
} else {
const entries = Object.entries(rawExtras);
if (entries.length > MAX_BACKEND_EXTRAS) {
failInvalidOption(`Backend extras must include at most ${MAX_BACKEND_EXTRAS} entries.`);
}
const extras: Record<string, string> = {};
for (const [entryKey, entryValue] of entries) {
const { key, value } = validateRuntimeConfigOptionInput(entryKey, entryValue);
extras[key] = value;
}
next.backendExtras = Object.keys(extras).length > 0 ? extras : undefined;
}
}
return next;
}
export function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function normalizeRuntimeOptions(
options: AcpSessionRuntimeOptions | undefined,
): AcpSessionRuntimeOptions {
const runtimeMode = normalizeText(options?.runtimeMode);
const model = normalizeText(options?.model);
const cwd = normalizeText(options?.cwd);
const permissionProfile = normalizeText(options?.permissionProfile);
let timeoutSeconds: number | undefined;
if (typeof options?.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds)) {
const rounded = Math.round(options.timeoutSeconds);
if (rounded > 0) {
timeoutSeconds = rounded;
}
}
const backendExtrasEntries = Object.entries(options?.backendExtras ?? {})
.map(([key, value]) => [normalizeText(key), normalizeText(value)] as const)
.filter(([key, value]) => Boolean(key && value)) as Array<[string, string]>;
const backendExtras =
backendExtrasEntries.length > 0 ? Object.fromEntries(backendExtrasEntries) : undefined;
return {
...(runtimeMode ? { runtimeMode } : {}),
...(model ? { model } : {}),
...(cwd ? { cwd } : {}),
...(permissionProfile ? { permissionProfile } : {}),
...(typeof timeoutSeconds === "number" ? { timeoutSeconds } : {}),
...(backendExtras ? { backendExtras } : {}),
};
}
export function mergeRuntimeOptions(params: {
current?: AcpSessionRuntimeOptions;
patch?: Partial<AcpSessionRuntimeOptions>;
}): AcpSessionRuntimeOptions {
const current = normalizeRuntimeOptions(params.current);
const patch = normalizeRuntimeOptions(validateRuntimeOptionPatch(params.patch));
const mergedExtras = {
...current.backendExtras,
...patch.backendExtras,
};
return normalizeRuntimeOptions({
...current,
...patch,
...(Object.keys(mergedExtras).length > 0 ? { backendExtras: mergedExtras } : {}),
});
}
export function resolveRuntimeOptionsFromMeta(meta: SessionAcpMeta): AcpSessionRuntimeOptions {
const normalized = normalizeRuntimeOptions(meta.runtimeOptions);
if (normalized.cwd || !meta.cwd) {
return normalized;
}
return normalizeRuntimeOptions({
...normalized,
cwd: meta.cwd,
});
}
export function runtimeOptionsEqual(
a: AcpSessionRuntimeOptions | undefined,
b: AcpSessionRuntimeOptions | undefined,
): boolean {
return JSON.stringify(normalizeRuntimeOptions(a)) === JSON.stringify(normalizeRuntimeOptions(b));
}
export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): string {
const normalized = normalizeRuntimeOptions(options);
const extras = Object.entries(normalized.backendExtras ?? {}).toSorted(([a], [b]) =>
a.localeCompare(b),
);
return JSON.stringify({
runtimeMode: normalized.runtimeMode ?? null,
model: normalized.model ?? null,
permissionProfile: normalized.permissionProfile ?? null,
timeoutSeconds: normalized.timeoutSeconds ?? null,
backendExtras: extras,
});
}
export function buildRuntimeConfigOptionPairs(
options: AcpSessionRuntimeOptions,
): Array<[string, string]> {
const normalized = normalizeRuntimeOptions(options);
const pairs = new Map<string, string>();
if (normalized.model) {
pairs.set("model", normalized.model);
}
if (normalized.permissionProfile) {
pairs.set("approval_policy", normalized.permissionProfile);
}
if (typeof normalized.timeoutSeconds === "number") {
pairs.set("timeout", String(normalized.timeoutSeconds));
}
for (const [key, value] of Object.entries(normalized.backendExtras ?? {})) {
if (!pairs.has(key)) {
pairs.set(key, value);
}
}
return [...pairs.entries()];
}
export function inferRuntimeOptionPatchFromConfigOption(
key: string,
value: string,
): Partial<AcpSessionRuntimeOptions> {
const validated = validateRuntimeConfigOptionInput(key, value);
const normalizedKey = validated.key.toLowerCase();
if (normalizedKey === "model") {
return { model: validateRuntimeModelInput(validated.value) };
}
if (
normalizedKey === "approval_policy" ||
normalizedKey === "permission_profile" ||
normalizedKey === "permissions"
) {
return { permissionProfile: validateRuntimePermissionProfileInput(validated.value) };
}
if (normalizedKey === "timeout" || normalizedKey === "timeout_seconds") {
return { timeoutSeconds: parseRuntimeTimeoutSecondsInput(validated.value) };
}
if (normalizedKey === "cwd") {
return { cwd: validateRuntimeCwdInput(validated.value) };
}
return {
backendExtras: {
[validated.key]: validated.value,
},
};
}

View File

@@ -0,0 +1,53 @@
export class SessionActorQueue {
private readonly tailBySession = new Map<string, Promise<void>>();
private readonly pendingBySession = new Map<string, number>();
getTailMapForTesting(): Map<string, Promise<void>> {
return this.tailBySession;
}
getTotalPendingCount(): number {
let total = 0;
for (const count of this.pendingBySession.values()) {
total += count;
}
return total;
}
getPendingCountForSession(actorKey: string): number {
return this.pendingBySession.get(actorKey) ?? 0;
}
async run<T>(actorKey: string, op: () => Promise<T>): Promise<T> {
const previous = this.tailBySession.get(actorKey) ?? Promise.resolve();
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
let release: () => void = () => {};
const marker = new Promise<void>((resolve) => {
release = resolve;
});
const queuedTail = previous
.catch(() => {
// Keep actor queue alive after an operation failure.
})
.then(() => marker);
this.tailBySession.set(actorKey, queuedTail);
await previous.catch(() => {
// Previous failures should not block newer commands.
});
try {
return await op();
} finally {
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
if (pending <= 0) {
this.pendingBySession.delete(actorKey);
} else {
this.pendingBySession.set(actorKey, pending);
}
release();
if (this.tailBySession.get(actorKey) === queuedTail) {
this.tailBySession.delete(actorKey);
}
}
}
}

View File

@@ -0,0 +1,77 @@
import type { OpenClawConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { getAcpSessionManager } from "./manager.js";
export type AcpSpawnRuntimeCloseHandle = {
runtime: {
close: (params: {
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
reason: string;
}) => Promise<void>;
};
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
};
export async function cleanupFailedAcpSpawn(params: {
cfg: OpenClawConfig;
sessionKey: string;
shouldDeleteSession: boolean;
deleteTranscript: boolean;
runtimeCloseHandle?: AcpSpawnRuntimeCloseHandle;
}): Promise<void> {
if (params.runtimeCloseHandle) {
await params.runtimeCloseHandle.runtime
.close({
handle: params.runtimeCloseHandle.handle,
reason: "spawn-failed",
})
.catch((err) => {
logVerbose(
`acp-spawn: runtime cleanup close failed for ${params.sessionKey}: ${String(err)}`,
);
});
}
const acpManager = getAcpSessionManager();
await acpManager
.closeSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
reason: "spawn-failed",
allowBackendUnavailable: true,
requireAcpSession: false,
})
.catch((err) => {
logVerbose(
`acp-spawn: manager cleanup close failed for ${params.sessionKey}: ${String(err)}`,
);
});
await getSessionBindingService()
.unbind({
targetSessionKey: params.sessionKey,
reason: "spawn-failed",
})
.catch((err) => {
logVerbose(
`acp-spawn: binding cleanup unbind failed for ${params.sessionKey}: ${String(err)}`,
);
});
if (!params.shouldDeleteSession) {
return;
}
await callGateway({
method: "sessions.delete",
params: {
key: params.sessionKey,
deleteTranscript: params.deleteTranscript,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
}).catch(() => {
// Best-effort cleanup only.
});
}

59
src/acp/policy.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
isAcpAgentAllowedByPolicy,
isAcpDispatchEnabledByPolicy,
isAcpEnabledByPolicy,
resolveAcpAgentPolicyError,
resolveAcpDispatchPolicyError,
resolveAcpDispatchPolicyMessage,
resolveAcpDispatchPolicyState,
} from "./policy.js";
describe("acp policy", () => {
it("treats ACP as enabled by default", () => {
const cfg = {} satisfies OpenClawConfig;
expect(isAcpEnabledByPolicy(cfg)).toBe(true);
expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false);
expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled");
});
it("reports ACP disabled state when acp.enabled is false", () => {
const cfg = {
acp: {
enabled: false,
},
} satisfies OpenClawConfig;
expect(isAcpEnabledByPolicy(cfg)).toBe(false);
expect(resolveAcpDispatchPolicyState(cfg)).toBe("acp_disabled");
expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.enabled=false");
expect(resolveAcpDispatchPolicyError(cfg)?.code).toBe("ACP_DISPATCH_DISABLED");
});
it("reports dispatch-disabled state when dispatch gate is false", () => {
const cfg = {
acp: {
enabled: true,
dispatch: {
enabled: false,
},
},
} satisfies OpenClawConfig;
expect(isAcpDispatchEnabledByPolicy(cfg)).toBe(false);
expect(resolveAcpDispatchPolicyState(cfg)).toBe("dispatch_disabled");
expect(resolveAcpDispatchPolicyMessage(cfg)).toContain("acp.dispatch.enabled=false");
});
it("applies allowlist filtering for ACP agents", () => {
const cfg = {
acp: {
allowedAgents: ["Codex", "claude-code"],
},
} satisfies OpenClawConfig;
expect(isAcpAgentAllowedByPolicy(cfg, "codex")).toBe(true);
expect(isAcpAgentAllowedByPolicy(cfg, "claude-code")).toBe(true);
expect(isAcpAgentAllowedByPolicy(cfg, "gemini")).toBe(false);
expect(resolveAcpAgentPolicyError(cfg, "gemini")?.code).toBe("ACP_SESSION_INIT_FAILED");
expect(resolveAcpAgentPolicyError(cfg, "codex")).toBeNull();
});
});

69
src/acp/policy.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { OpenClawConfig } from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { AcpRuntimeError } from "./runtime/errors.js";
const ACP_DISABLED_MESSAGE = "ACP is disabled by policy (`acp.enabled=false`).";
const ACP_DISPATCH_DISABLED_MESSAGE =
"ACP dispatch is disabled by policy (`acp.dispatch.enabled=false`).";
export type AcpDispatchPolicyState = "enabled" | "acp_disabled" | "dispatch_disabled";
export function isAcpEnabledByPolicy(cfg: OpenClawConfig): boolean {
return cfg.acp?.enabled !== false;
}
export function resolveAcpDispatchPolicyState(cfg: OpenClawConfig): AcpDispatchPolicyState {
if (!isAcpEnabledByPolicy(cfg)) {
return "acp_disabled";
}
if (cfg.acp?.dispatch?.enabled !== true) {
return "dispatch_disabled";
}
return "enabled";
}
export function isAcpDispatchEnabledByPolicy(cfg: OpenClawConfig): boolean {
return resolveAcpDispatchPolicyState(cfg) === "enabled";
}
export function resolveAcpDispatchPolicyMessage(cfg: OpenClawConfig): string | null {
const state = resolveAcpDispatchPolicyState(cfg);
if (state === "acp_disabled") {
return ACP_DISABLED_MESSAGE;
}
if (state === "dispatch_disabled") {
return ACP_DISPATCH_DISABLED_MESSAGE;
}
return null;
}
export function resolveAcpDispatchPolicyError(cfg: OpenClawConfig): AcpRuntimeError | null {
const message = resolveAcpDispatchPolicyMessage(cfg);
if (!message) {
return null;
}
return new AcpRuntimeError("ACP_DISPATCH_DISABLED", message);
}
export function isAcpAgentAllowedByPolicy(cfg: OpenClawConfig, agentId: string): boolean {
const allowed = (cfg.acp?.allowedAgents ?? [])
.map((entry) => normalizeAgentId(entry))
.filter(Boolean);
if (allowed.length === 0) {
return true;
}
return allowed.includes(normalizeAgentId(agentId));
}
export function resolveAcpAgentPolicyError(
cfg: OpenClawConfig,
agentId: string,
): AcpRuntimeError | null {
if (isAcpAgentAllowedByPolicy(cfg, agentId)) {
return null;
}
return new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP agent "${normalizeAgentId(agentId)}" is not allowed by policy.`,
);
}

View File

@@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { expect } from "vitest";
import { toAcpRuntimeError } from "./errors.js";
import type { AcpRuntime, AcpRuntimeEvent } from "./types.js";
export type AcpRuntimeAdapterContractParams = {
createRuntime: () => Promise<AcpRuntime> | AcpRuntime;
agentId?: string;
successPrompt?: string;
errorPrompt?: string;
assertSuccessEvents?: (events: AcpRuntimeEvent[]) => void | Promise<void>;
assertErrorOutcome?: (params: {
events: AcpRuntimeEvent[];
thrown: unknown;
}) => void | Promise<void>;
};
export async function runAcpRuntimeAdapterContract(
params: AcpRuntimeAdapterContractParams,
): Promise<void> {
const runtime = await params.createRuntime();
const sessionKey = `agent:${params.agentId ?? "codex"}:acp:contract-${randomUUID()}`;
const agent = params.agentId ?? "codex";
const handle = await runtime.ensureSession({
sessionKey,
agent,
mode: "persistent",
});
expect(handle.sessionKey).toBe(sessionKey);
expect(handle.backend.trim()).not.toHaveLength(0);
expect(handle.runtimeSessionName.trim()).not.toHaveLength(0);
const successEvents: AcpRuntimeEvent[] = [];
for await (const event of runtime.runTurn({
handle,
text: params.successPrompt ?? "contract-success",
mode: "prompt",
requestId: `contract-success-${randomUUID()}`,
})) {
successEvents.push(event);
}
expect(
successEvents.some(
(event) =>
event.type === "done" ||
event.type === "text_delta" ||
event.type === "status" ||
event.type === "tool_call",
),
).toBe(true);
await params.assertSuccessEvents?.(successEvents);
if (runtime.getStatus) {
const status = await runtime.getStatus({ handle });
expect(status).toBeDefined();
expect(typeof status).toBe("object");
}
if (runtime.setMode) {
await runtime.setMode({
handle,
mode: "contract",
});
}
if (runtime.setConfigOption) {
await runtime.setConfigOption({
handle,
key: "contract_key",
value: "contract_value",
});
}
let errorThrown: unknown = null;
const errorEvents: AcpRuntimeEvent[] = [];
const errorPrompt = params.errorPrompt?.trim();
if (errorPrompt) {
try {
for await (const event of runtime.runTurn({
handle,
text: errorPrompt,
mode: "prompt",
requestId: `contract-error-${randomUUID()}`,
})) {
errorEvents.push(event);
}
} catch (error) {
errorThrown = error;
}
const sawErrorEvent = errorEvents.some((event) => event.type === "error");
expect(Boolean(errorThrown) || sawErrorEvent).toBe(true);
if (errorThrown) {
const acpError = toAcpRuntimeError({
error: errorThrown,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP runtime contract expected an error turn failure.",
});
expect(acpError.code.length).toBeGreaterThan(0);
expect(acpError.message.length).toBeGreaterThan(0);
}
}
await params.assertErrorOutcome?.({
events: errorEvents,
thrown: errorThrown,
});
await runtime.cancel({
handle,
reason: "contract-cancel",
});
await runtime.close({
handle,
reason: "contract-close",
});
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { formatAcpRuntimeErrorText } from "./error-text.js";
import { AcpRuntimeError } from "./errors.js";
describe("formatAcpRuntimeErrorText", () => {
it("adds actionable next steps for known ACP runtime error codes", () => {
const text = formatAcpRuntimeErrorText(
new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing"),
);
expect(text).toContain("ACP error (ACP_BACKEND_MISSING): backend missing");
expect(text).toContain("next:");
});
it("returns consistent ACP error envelope for runtime failures", () => {
const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed"));
expect(text).toContain("ACP error (ACP_TURN_FAILED): turn failed");
expect(text).toContain("next:");
});
});

View File

@@ -0,0 +1,45 @@
import { type AcpRuntimeErrorCode, AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
function resolveAcpRuntimeErrorNextStep(error: AcpRuntimeError): string | undefined {
if (error.code === "ACP_BACKEND_MISSING" || error.code === "ACP_BACKEND_UNAVAILABLE") {
return "Run `/acp doctor`, install/enable the backend plugin, then retry.";
}
if (error.code === "ACP_DISPATCH_DISABLED") {
return "Enable `acp.dispatch.enabled=true` to allow thread-message ACP turns.";
}
if (error.code === "ACP_SESSION_INIT_FAILED") {
return "If this session is stale, recreate it with `/acp spawn` and rebind the thread.";
}
if (error.code === "ACP_INVALID_RUNTIME_OPTION") {
return "Use `/acp status` to inspect options and pass valid values.";
}
if (error.code === "ACP_BACKEND_UNSUPPORTED_CONTROL") {
return "This backend does not support that control; use a supported command.";
}
if (error.code === "ACP_TURN_FAILED") {
return "Retry, or use `/acp cancel` and send the message again.";
}
return undefined;
}
export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string {
const next = resolveAcpRuntimeErrorNextStep(error);
if (!next) {
return `ACP error (${error.code}): ${error.message}`;
}
return `ACP error (${error.code}): ${error.message}\nnext: ${next}`;
}
export function toAcpRuntimeErrorText(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): string {
return formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: params.error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
}),
);
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "./errors.js";
describe("withAcpRuntimeErrorBoundary", () => {
it("wraps generic errors with fallback code and source message", async () => {
await expect(
withAcpRuntimeErrorBoundary({
run: async () => {
throw new Error("boom");
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
).rejects.toMatchObject({
name: "AcpRuntimeError",
code: "ACP_TURN_FAILED",
message: "boom",
});
});
it("passes through existing ACP runtime errors", async () => {
const existing = new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing");
await expect(
withAcpRuntimeErrorBoundary({
run: async () => {
throw existing;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
).rejects.toBe(existing);
});
});

61
src/acp/runtime/errors.ts Normal file
View File

@@ -0,0 +1,61 @@
export const ACP_ERROR_CODES = [
"ACP_BACKEND_MISSING",
"ACP_BACKEND_UNAVAILABLE",
"ACP_BACKEND_UNSUPPORTED_CONTROL",
"ACP_DISPATCH_DISABLED",
"ACP_INVALID_RUNTIME_OPTION",
"ACP_SESSION_INIT_FAILED",
"ACP_TURN_FAILED",
] as const;
export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number];
export class AcpRuntimeError extends Error {
readonly code: AcpRuntimeErrorCode;
override readonly cause?: unknown;
constructor(code: AcpRuntimeErrorCode, message: string, options?: { cause?: unknown }) {
super(message);
this.name = "AcpRuntimeError";
this.code = code;
this.cause = options?.cause;
}
}
export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError {
return value instanceof AcpRuntimeError;
}
export function toAcpRuntimeError(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): AcpRuntimeError {
if (params.error instanceof AcpRuntimeError) {
return params.error;
}
if (params.error instanceof Error) {
return new AcpRuntimeError(params.fallbackCode, params.error.message, {
cause: params.error,
});
}
return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, {
cause: params.error,
});
}
export async function withAcpRuntimeErrorBoundary<T>(params: {
run: () => Promise<T>;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): Promise<T> {
try {
return await params.run();
} catch (error) {
throw toAcpRuntimeError({
error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
});
}
}

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "./errors.js";
import {
__testing,
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
requireAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "./registry.js";
import type { AcpRuntime } from "./types.js";
function createRuntimeStub(): AcpRuntime {
return {
ensureSession: vi.fn(async (input) => ({
sessionKey: input.sessionKey,
backend: "stub",
runtimeSessionName: `${input.sessionKey}:runtime`,
})),
runTurn: vi.fn(async function* () {
// no-op stream
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
}
describe("acp runtime registry", () => {
beforeEach(() => {
__testing.resetAcpRuntimeBackendsForTests();
});
it("registers and resolves backends by id", () => {
const runtime = createRuntimeStub();
registerAcpRuntimeBackend({ id: "acpx", runtime });
const backend = getAcpRuntimeBackend("acpx");
expect(backend?.id).toBe("acpx");
expect(backend?.runtime).toBe(runtime);
});
it("prefers a healthy backend when resolving without explicit id", () => {
const unhealthyRuntime = createRuntimeStub();
const healthyRuntime = createRuntimeStub();
registerAcpRuntimeBackend({
id: "unhealthy",
runtime: unhealthyRuntime,
healthy: () => false,
});
registerAcpRuntimeBackend({
id: "healthy",
runtime: healthyRuntime,
healthy: () => true,
});
const backend = getAcpRuntimeBackend();
expect(backend?.id).toBe("healthy");
});
it("throws a typed missing-backend error when no backend is registered", () => {
expect(() => requireAcpRuntimeBackend()).toThrowError(AcpRuntimeError);
expect(() => requireAcpRuntimeBackend()).toThrowError(/ACP runtime backend is not configured/i);
});
it("throws a typed unavailable error when the requested backend is unhealthy", () => {
registerAcpRuntimeBackend({
id: "acpx",
runtime: createRuntimeStub(),
healthy: () => false,
});
try {
requireAcpRuntimeBackend("acpx");
throw new Error("expected requireAcpRuntimeBackend to throw");
} catch (err) {
expect(err).toBeInstanceOf(AcpRuntimeError);
expect((err as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
}
});
it("unregisters a backend by id", () => {
registerAcpRuntimeBackend({ id: "acpx", runtime: createRuntimeStub() });
unregisterAcpRuntimeBackend("acpx");
expect(getAcpRuntimeBackend("acpx")).toBeNull();
});
it("keeps backend state on a global registry for cross-loader access", () => {
const runtime = createRuntimeStub();
const sharedState = __testing.getAcpRuntimeRegistryGlobalStateForTests();
sharedState.backendsById.set("acpx", {
id: "acpx",
runtime,
});
const backend = getAcpRuntimeBackend("acpx");
expect(backend?.runtime).toBe(runtime);
});
});

118
src/acp/runtime/registry.ts Normal file
View File

@@ -0,0 +1,118 @@
import { AcpRuntimeError } from "./errors.js";
import type { AcpRuntime } from "./types.js";
export type AcpRuntimeBackend = {
id: string;
runtime: AcpRuntime;
healthy?: () => boolean;
};
type AcpRuntimeRegistryGlobalState = {
backendsById: Map<string, AcpRuntimeBackend>;
};
const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState");
function createAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
return {
backendsById: new Map<string, AcpRuntimeBackend>(),
};
}
function resolveAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
const runtimeGlobal = globalThis as typeof globalThis & {
[ACP_RUNTIME_REGISTRY_STATE_KEY]?: AcpRuntimeRegistryGlobalState;
};
if (!runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]) {
runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY] = createAcpRuntimeRegistryGlobalState();
}
return runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY];
}
const ACP_BACKENDS_BY_ID = resolveAcpRuntimeRegistryGlobalState().backendsById;
function normalizeBackendId(id: string | undefined): string {
return id?.trim().toLowerCase() || "";
}
function isBackendHealthy(backend: AcpRuntimeBackend): boolean {
if (!backend.healthy) {
return true;
}
try {
return backend.healthy();
} catch {
return false;
}
}
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void {
const id = normalizeBackendId(backend.id);
if (!id) {
throw new Error("ACP runtime backend id is required");
}
if (!backend.runtime) {
throw new Error(`ACP runtime backend "${id}" is missing runtime implementation`);
}
ACP_BACKENDS_BY_ID.set(id, {
...backend,
id,
});
}
export function unregisterAcpRuntimeBackend(id: string): void {
const normalized = normalizeBackendId(id);
if (!normalized) {
return;
}
ACP_BACKENDS_BY_ID.delete(normalized);
}
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null {
const normalized = normalizeBackendId(id);
if (normalized) {
return ACP_BACKENDS_BY_ID.get(normalized) ?? null;
}
if (ACP_BACKENDS_BY_ID.size === 0) {
return null;
}
for (const backend of ACP_BACKENDS_BY_ID.values()) {
if (isBackendHealthy(backend)) {
return backend;
}
}
return ACP_BACKENDS_BY_ID.values().next().value ?? null;
}
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend {
const normalized = normalizeBackendId(id);
const backend = getAcpRuntimeBackend(normalized || undefined);
if (!backend) {
throw new AcpRuntimeError(
"ACP_BACKEND_MISSING",
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
);
}
if (!isBackendHealthy(backend)) {
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
"ACP runtime backend is currently unavailable. Try again in a moment.",
);
}
if (normalized && backend.id !== normalized) {
throw new AcpRuntimeError(
"ACP_BACKEND_MISSING",
`ACP runtime backend "${normalized}" is not registered.`,
);
}
return backend;
}
export const __testing = {
resetAcpRuntimeBackendsForTests() {
ACP_BACKENDS_BY_ID.clear();
},
getAcpRuntimeRegistryGlobalStateForTests() {
return resolveAcpRuntimeRegistryGlobalState();
},
};

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
resolveAcpSessionCwd,
resolveAcpSessionIdentifierLinesFromIdentity,
resolveAcpThreadSessionDetailLines,
} from "./session-identifiers.js";
describe("session identifier helpers", () => {
it("hides unresolved identifiers from thread intro details while pending", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:pending-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "pending",
source: "ensure",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toEqual([]);
});
it("adds a Codex resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:resolved-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toContain("agent session id: inner-123");
expect(lines).toContain("acpx session id: acpx-123");
expect(lines).toContain(
"resume in Codex CLI: `codex resume inner-123` (continues this conversation).",
);
});
it("shows pending identity text for status rendering", () => {
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: "acpx",
mode: "status",
identity: {
state: "pending",
source: "status",
lastUpdatedAt: Date.now(),
agentSessionId: "inner-123",
},
});
expect(lines).toEqual(["session ids: pending (available after the first reply)"]);
});
it("prefers runtimeOptions.cwd over legacy meta.cwd", () => {
const cwd = resolveAcpSessionCwd({
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
runtimeOptions: {
cwd: "/repo/new",
},
cwd: "/repo/old",
state: "idle",
lastActivityAt: Date.now(),
});
expect(cwd).toBe("/repo/new");
});
});

View File

@@ -0,0 +1,131 @@
import type { SessionAcpIdentity, SessionAcpMeta } from "../../config/sessions/types.js";
import { isSessionIdentityPending, resolveSessionIdentityFromMeta } from "./session-identity.js";
export const ACP_SESSION_IDENTITY_RENDERER_VERSION = "v1";
export type AcpSessionIdentifierRenderMode = "status" | "thread";
type SessionResumeHintResolver = (params: { agentSessionId: string }) => string;
const ACP_AGENT_RESUME_HINT_BY_KEY = new Map<string, SessionResumeHintResolver>([
[
"codex",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"openai-codex",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"codex-cli",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
]);
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeAgentHintKey(value: unknown): string | undefined {
const normalized = normalizeText(value);
if (!normalized) {
return undefined;
}
return normalized.toLowerCase().replace(/[\s_]+/g, "-");
}
function resolveAcpAgentResumeHintLine(params: {
agentId?: string;
agentSessionId?: string;
}): string | undefined {
const agentSessionId = normalizeText(params.agentSessionId);
const agentKey = normalizeAgentHintKey(params.agentId);
if (!agentSessionId || !agentKey) {
return undefined;
}
const resolver = ACP_AGENT_RESUME_HINT_BY_KEY.get(agentKey);
return resolver ? resolver({ agentSessionId }) : undefined;
}
export function resolveAcpSessionIdentifierLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const backend = normalizeText(params.meta?.backend) ?? "backend";
const identity = resolveSessionIdentityFromMeta(params.meta);
return resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "status",
});
}
export function resolveAcpSessionIdentifierLinesFromIdentity(params: {
backend: string;
identity?: SessionAcpIdentity;
mode?: AcpSessionIdentifierRenderMode;
}): string[] {
const backend = normalizeText(params.backend) ?? "backend";
const mode = params.mode ?? "status";
const identity = params.identity;
const agentSessionId = normalizeText(identity?.agentSessionId);
const acpxSessionId = normalizeText(identity?.acpxSessionId);
const acpxRecordId = normalizeText(identity?.acpxRecordId);
const hasIdentifier = Boolean(agentSessionId || acpxSessionId || acpxRecordId);
if (isSessionIdentityPending(identity) && hasIdentifier) {
if (mode === "status") {
return ["session ids: pending (available after the first reply)"];
}
return [];
}
const lines: string[] = [];
if (agentSessionId) {
lines.push(`agent session id: ${agentSessionId}`);
}
if (acpxSessionId) {
lines.push(`${backend} session id: ${acpxSessionId}`);
}
if (acpxRecordId) {
lines.push(`${backend} record id: ${acpxRecordId}`);
}
return lines;
}
export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined {
const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd);
if (runtimeCwd) {
return runtimeCwd;
}
return normalizeText(meta?.cwd);
}
export function resolveAcpThreadSessionDetailLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const meta = params.meta;
const identity = resolveSessionIdentityFromMeta(meta);
const backend = normalizeText(meta?.backend) ?? "backend";
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "thread",
});
if (lines.length === 0) {
return lines;
}
const hint = resolveAcpAgentResumeHintLine({
agentId: meta?.agent,
agentSessionId: identity?.agentSessionId,
});
if (hint) {
lines.push(hint);
}
return lines;
}

View File

@@ -0,0 +1,210 @@
import type {
SessionAcpIdentity,
SessionAcpIdentitySource,
SessionAcpMeta,
} from "../../config/sessions/types.js";
import type { AcpRuntimeHandle, AcpRuntimeStatus } from "./types.js";
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeIdentityState(value: unknown): SessionAcpIdentity["state"] | undefined {
if (value !== "pending" && value !== "resolved") {
return undefined;
}
return value;
}
function normalizeIdentitySource(value: unknown): SessionAcpIdentitySource | undefined {
if (value !== "ensure" && value !== "status" && value !== "event") {
return undefined;
}
return value;
}
function normalizeIdentity(
identity: SessionAcpIdentity | undefined,
): SessionAcpIdentity | undefined {
if (!identity) {
return undefined;
}
const state = normalizeIdentityState(identity.state);
const source = normalizeIdentitySource(identity.source);
const acpxRecordId = normalizeText(identity.acpxRecordId);
const acpxSessionId = normalizeText(identity.acpxSessionId);
const agentSessionId = normalizeText(identity.agentSessionId);
const lastUpdatedAt =
typeof identity.lastUpdatedAt === "number" && Number.isFinite(identity.lastUpdatedAt)
? identity.lastUpdatedAt
: undefined;
const hasAnyId = Boolean(acpxRecordId || acpxSessionId || agentSessionId);
if (!state && !source && !hasAnyId && lastUpdatedAt === undefined) {
return undefined;
}
const resolved = Boolean(acpxSessionId || agentSessionId);
const normalizedState = state ?? (resolved ? "resolved" : "pending");
return {
state: normalizedState,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: source ?? "status",
lastUpdatedAt: lastUpdatedAt ?? Date.now(),
};
}
export function resolveSessionIdentityFromMeta(
meta: SessionAcpMeta | undefined,
): SessionAcpIdentity | undefined {
if (!meta) {
return undefined;
}
return normalizeIdentity(meta.identity);
}
export function identityHasStableSessionId(identity: SessionAcpIdentity | undefined): boolean {
return Boolean(identity?.acpxSessionId || identity?.agentSessionId);
}
export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean {
if (!identity) {
return true;
}
return identity.state === "pending";
}
export function identityEquals(
left: SessionAcpIdentity | undefined,
right: SessionAcpIdentity | undefined,
): boolean {
const a = normalizeIdentity(left);
const b = normalizeIdentity(right);
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.state === b.state &&
a.acpxRecordId === b.acpxRecordId &&
a.acpxSessionId === b.acpxSessionId &&
a.agentSessionId === b.agentSessionId &&
a.source === b.source
);
}
export function mergeSessionIdentity(params: {
current: SessionAcpIdentity | undefined;
incoming: SessionAcpIdentity | undefined;
now: number;
}): SessionAcpIdentity | undefined {
const current = normalizeIdentity(params.current);
const incoming = normalizeIdentity(params.incoming);
if (!current) {
if (!incoming) {
return undefined;
}
return { ...incoming, lastUpdatedAt: params.now };
}
if (!incoming) {
return current;
}
const currentResolved = current.state === "resolved";
const incomingResolved = incoming.state === "resolved";
const allowIncomingValue = !currentResolved || incomingResolved;
const nextRecordId =
allowIncomingValue && incoming.acpxRecordId ? incoming.acpxRecordId : current.acpxRecordId;
const nextAcpxSessionId =
allowIncomingValue && incoming.acpxSessionId ? incoming.acpxSessionId : current.acpxSessionId;
const nextAgentSessionId =
allowIncomingValue && incoming.agentSessionId
? incoming.agentSessionId
: current.agentSessionId;
const nextResolved = Boolean(nextAcpxSessionId || nextAgentSessionId);
const nextState: SessionAcpIdentity["state"] = nextResolved
? "resolved"
: currentResolved
? "resolved"
: incoming.state;
const nextSource = allowIncomingValue ? incoming.source : current.source;
const next: SessionAcpIdentity = {
state: nextState,
...(nextRecordId ? { acpxRecordId: nextRecordId } : {}),
...(nextAcpxSessionId ? { acpxSessionId: nextAcpxSessionId } : {}),
...(nextAgentSessionId ? { agentSessionId: nextAgentSessionId } : {}),
source: nextSource,
lastUpdatedAt: params.now,
};
return next;
}
export function createIdentityFromEnsure(params: {
handle: AcpRuntimeHandle;
now: number;
}): SessionAcpIdentity | undefined {
const acpxRecordId = normalizeText((params.handle as { acpxRecordId?: unknown }).acpxRecordId);
const acpxSessionId = normalizeText(params.handle.backendSessionId);
const agentSessionId = normalizeText(params.handle.agentSessionId);
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
return {
state: "pending",
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: "ensure",
lastUpdatedAt: params.now,
};
}
export function createIdentityFromStatus(params: {
status: AcpRuntimeStatus | undefined;
now: number;
}): SessionAcpIdentity | undefined {
if (!params.status) {
return undefined;
}
const details = params.status.details;
const acpxRecordId =
normalizeText((params.status as { acpxRecordId?: unknown }).acpxRecordId) ??
normalizeText(details?.acpxRecordId);
const acpxSessionId =
normalizeText(params.status.backendSessionId) ??
normalizeText(details?.backendSessionId) ??
normalizeText(details?.acpxSessionId);
const agentSessionId =
normalizeText(params.status.agentSessionId) ?? normalizeText(details?.agentSessionId);
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
const resolved = Boolean(acpxSessionId || agentSessionId);
return {
state: resolved ? "resolved" : "pending",
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: "status",
lastUpdatedAt: params.now,
};
}
export function resolveRuntimeHandleIdentifiersFromIdentity(
identity: SessionAcpIdentity | undefined,
): { backendSessionId?: string; agentSessionId?: string } {
if (!identity) {
return {};
}
return {
...(identity.acpxSessionId ? { backendSessionId: identity.acpxSessionId } : {}),
...(identity.agentSessionId ? { agentSessionId: identity.agentSessionId } : {}),
};
}

View File

@@ -0,0 +1,165 @@
import path from "node:path";
import { resolveAgentSessionDirs } from "../../agents/session-dirs.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { resolveStateDir } from "../../config/paths.js";
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
import {
mergeSessionEntry,
type SessionAcpMeta,
type SessionEntry,
} from "../../config/sessions/types.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
export type AcpSessionStoreEntry = {
cfg: OpenClawConfig;
storePath: string;
sessionKey: string;
storeSessionKey: string;
entry?: SessionEntry;
acp?: SessionAcpMeta;
storeReadFailed?: boolean;
};
function resolveStoreSessionKey(store: Record<string, SessionEntry>, sessionKey: string): string {
const normalized = sessionKey.trim();
if (!normalized) {
return "";
}
if (store[normalized]) {
return normalized;
}
const lower = normalized.toLowerCase();
if (store[lower]) {
return lower;
}
for (const key of Object.keys(store)) {
if (key.toLowerCase() === lower) {
return key;
}
}
return lower;
}
export function resolveSessionStorePathForAcp(params: {
sessionKey: string;
cfg?: OpenClawConfig;
}): { cfg: OpenClawConfig; storePath: string } {
const cfg = params.cfg ?? loadConfig();
const parsed = parseAgentSessionKey(params.sessionKey);
const storePath = resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
});
return { cfg, storePath };
}
export function readAcpSessionEntry(params: {
sessionKey: string;
cfg?: OpenClawConfig;
}): AcpSessionStoreEntry | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const { cfg, storePath } = resolveSessionStorePathForAcp({
sessionKey,
cfg: params.cfg,
});
let store: Record<string, SessionEntry>;
let storeReadFailed = false;
try {
store = loadSessionStore(storePath);
} catch {
storeReadFailed = true;
store = {};
}
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
const entry = store[storeSessionKey];
return {
cfg,
storePath,
sessionKey,
storeSessionKey,
entry,
acp: entry?.acp,
storeReadFailed,
};
}
export async function listAcpSessionEntries(params: {
cfg?: OpenClawConfig;
}): Promise<AcpSessionStoreEntry[]> {
const cfg = params.cfg ?? loadConfig();
const stateDir = resolveStateDir(process.env);
const sessionDirs = await resolveAgentSessionDirs(stateDir);
const entries: AcpSessionStoreEntry[] = [];
for (const sessionsDir of sessionDirs) {
const storePath = path.join(sessionsDir, "sessions.json");
let store: Record<string, SessionEntry>;
try {
store = loadSessionStore(storePath);
} catch {
continue;
}
for (const [sessionKey, entry] of Object.entries(store)) {
if (!entry?.acp) {
continue;
}
entries.push({
cfg,
storePath,
sessionKey,
storeSessionKey: sessionKey,
entry,
acp: entry.acp,
});
}
}
return entries;
}
export async function upsertAcpSessionMeta(params: {
sessionKey: string;
cfg?: OpenClawConfig;
mutate: (
current: SessionAcpMeta | undefined,
entry: SessionEntry | undefined,
) => SessionAcpMeta | null | undefined;
}): Promise<SessionEntry | null> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const { storePath } = resolveSessionStorePathForAcp({
sessionKey,
cfg: params.cfg,
});
return await updateSessionStore(
storePath,
(store) => {
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
const currentEntry = store[storeSessionKey];
const nextMeta = params.mutate(currentEntry?.acp, currentEntry);
if (nextMeta === undefined) {
return currentEntry ?? null;
}
if (nextMeta === null && !currentEntry) {
return null;
}
const nextEntry = mergeSessionEntry(currentEntry, {
acp: nextMeta ?? undefined,
});
if (nextMeta === null) {
delete nextEntry.acp;
}
store[storeSessionKey] = nextEntry;
return nextEntry;
},
{
activeSessionKey: sessionKey.toLowerCase(),
},
);
}

110
src/acp/runtime/types.ts Normal file
View File

@@ -0,0 +1,110 @@
export type AcpRuntimePromptMode = "prompt" | "steer";
export type AcpRuntimeSessionMode = "persistent" | "oneshot";
export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status";
export type AcpRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
/** Effective runtime working directory for this ACP session, if exposed by adapter/runtime. */
cwd?: string;
/** Backend-local record identifier, if exposed by adapter/runtime (for example acpx record id). */
acpxRecordId?: string;
/** Backend-level ACP session identifier, if exposed by adapter/runtime. */
backendSessionId?: string;
/** Upstream harness session identifier, if exposed by adapter/runtime. */
agentSessionId?: string;
};
export type AcpRuntimeEnsureInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
env?: Record<string, string>;
};
export type AcpRuntimeTurnInput = {
handle: AcpRuntimeHandle;
text: string;
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;
};
export type AcpRuntimeCapabilities = {
controls: AcpRuntimeControl[];
/**
* Optional backend-advertised option keys for session/set_config_option.
* Empty/undefined means "backend accepts keys, but did not advertise a strict list".
*/
configOptionKeys?: string[];
};
export type AcpRuntimeStatus = {
summary?: string;
/** Backend-local record identifier, if exposed by adapter/runtime. */
acpxRecordId?: string;
/** Backend-level ACP session identifier, if known at status time. */
backendSessionId?: string;
/** Upstream harness session identifier, if known at status time. */
agentSessionId?: string;
details?: Record<string, unknown>;
};
export type AcpRuntimeDoctorReport = {
ok: boolean;
code?: string;
message: string;
installCommand?: string;
details?: string[];
};
export type AcpRuntimeEvent =
| {
type: "text_delta";
text: string;
stream?: "output" | "thought";
}
| {
type: "status";
text: string;
}
| {
type: "tool_call";
text: string;
}
| {
type: "done";
stopReason?: string;
}
| {
type: "error";
message: string;
code?: string;
retryable?: boolean;
};
export interface AcpRuntime {
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities?(input: {
handle?: AcpRuntimeHandle;
}): Promise<AcpRuntimeCapabilities> | AcpRuntimeCapabilities;
getStatus?(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus>;
setMode?(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
doctor?(): Promise<AcpRuntimeDoctorReport>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}

View File

@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
type GuardedSource = {
path: string;
forbiddenPatterns: RegExp[];
};
const GUARDED_SOURCES: GuardedSource[] = [
{
path: "agents/acp-spawn.ts",
forbiddenPatterns: [/\bgetThreadBindingManager\b/, /\bparseDiscordTarget\b/],
},
{
path: "auto-reply/reply/commands-acp/lifecycle.ts",
forbiddenPatterns: [/\bgetThreadBindingManager\b/, /\bunbindThreadBindingsBySessionKey\b/],
},
{
path: "auto-reply/reply/commands-acp/targets.ts",
forbiddenPatterns: [/\bgetThreadBindingManager\b/],
},
{
path: "auto-reply/reply/commands-subagents/action-focus.ts",
forbiddenPatterns: [/\bgetThreadBindingManager\b/],
},
];
describe("ACP/session binding architecture guardrails", () => {
it("keeps ACP/focus flows off Discord thread-binding manager APIs", () => {
for (const source of GUARDED_SOURCES) {
const absolutePath = resolve(ROOT_DIR, source.path);
const text = readFileSync(absolutePath, "utf8");
for (const pattern of source.forbiddenPatterns) {
expect(text).not.toMatch(pattern);
}
}
});
});

View File

@@ -0,0 +1,373 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
const sessionBindingCapabilitiesMock = vi.fn();
const sessionBindingBindMock = vi.fn();
const sessionBindingUnbindMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
const sessionBindingListBySessionMock = vi.fn();
const closeSessionMock = vi.fn();
const initializeSessionMock = vi.fn();
const state = {
cfg: {
acp: {
enabled: true,
backend: "acpx",
allowedAgents: ["codex"],
},
session: {
mainKey: "main",
scope: "per-sender",
},
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} as OpenClawConfig,
};
return {
callGatewayMock,
sessionBindingCapabilitiesMock,
sessionBindingBindMock,
sessionBindingUnbindMock,
sessionBindingResolveByConversationMock,
sessionBindingListBySessionMock,
closeSessionMock,
initializeSessionMock,
state,
};
});
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => hoisted.state.cfg,
};
});
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
}));
vi.mock("../acp/control-plane/manager.js", () => {
return {
getAcpSessionManager: () => ({
initializeSession: (params: unknown) => hoisted.initializeSessionMock(params),
closeSession: (params: unknown) => hoisted.closeSessionMock(params),
}),
};
});
vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
}),
};
});
const { spawnAcpDirect } = await import("./acp-spawn.js");
function createSessionBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:child-thread",
targetSessionKey: "agent:codex:acp:s1",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "child-thread",
parentConversationId: "parent-channel",
},
status: "active",
boundAt: Date.now(),
metadata: {
agentId: "codex",
boundBy: "system",
},
...overrides,
};
}
describe("spawnAcpDirect", () => {
beforeEach(() => {
hoisted.state.cfg = {
acp: {
enabled: true,
backend: "acpx",
allowedAgents: ["codex"],
},
session: {
mainKey: "main",
scope: "per-sender",
},
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
const args = argsUnknown as { method?: string };
if (args.method === "sessions.patch") {
return { ok: true };
}
if (args.method === "agent") {
return { runId: "run-1" };
}
if (args.method === "sessions.delete") {
return { ok: true };
}
return {};
});
hoisted.closeSessionMock.mockReset().mockResolvedValue({
runtimeClosed: true,
metaCleared: false,
});
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
const args = argsUnknown as {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
};
const runtimeSessionName = `${args.sessionKey}:runtime`;
const cwd = typeof args.cwd === "string" ? args.cwd : undefined;
return {
runtime: {
close: vi.fn().mockResolvedValue(undefined),
},
handle: {
sessionKey: args.sessionKey,
backend: "acpx",
runtimeSessionName,
...(cwd ? { cwd } : {}),
agentSessionId: "codex-inner-1",
backendSessionId: "acpx-1",
},
meta: {
backend: "acpx",
agent: args.agent,
runtimeSessionName,
...(cwd ? { runtimeOptions: { cwd }, cwd } : {}),
identity: {
state: "pending",
source: "ensure",
acpxSessionId: "acpx-1",
agentSessionId: "codex-inner-1",
lastUpdatedAt: Date.now(),
},
mode: args.mode,
state: "idle",
lastActivityAt: Date.now(),
},
};
});
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
hoisted.sessionBindingBindMock
.mockReset()
.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { accountId: string };
metadata?: Record<string, unknown>;
}) =>
createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
accountId: input.conversation.accountId,
conversationId: "child-thread",
parentConversationId: "parent-channel",
},
metadata: {
boundBy:
typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system",
agentId: "codex",
webhookId: "wh-1",
},
}),
);
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
});
it("spawns ACP session, binds a new thread, and dispatches initial task", async () => {
const result = await spawnAcpDirect(
{
task: "Investigate flaky tests",
agentId: "codex",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:parent-channel",
agentThreadId: "requester-thread",
},
);
expect(result.status).toBe("accepted");
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
expect(result.runId).toBe("run-1");
expect(result.mode).toBe("session");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
targetKind: "session",
placement: "child",
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.not.stringContaining(
"session ids: pending (available after the first reply)",
),
}),
}),
);
const agentCall = hoisted.callGatewayMock.mock.calls
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
.find((request) => request.method === "agent");
expect(agentCall?.params?.sessionKey).toMatch(/^agent:codex:acp:/);
expect(agentCall?.params?.to).toBe("channel:child-thread");
expect(agentCall?.params?.threadId).toBe("child-thread");
expect(agentCall?.params?.deliver).toBe(true);
expect(hoisted.initializeSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: expect.stringMatching(/^agent:codex:acp:/),
agent: "codex",
mode: "persistent",
}),
);
});
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
const result = await spawnAcpDirect(
{
task: "Check workspace",
agentId: "codex",
cwd: "/home/bob/clawd",
mode: "session",
thread: true,
},
{
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:parent-channel",
},
);
expect(result.status).toBe("accepted");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.stringContaining("cwd: /home/bob/clawd"),
}),
}),
);
});
it("rejects disallowed ACP agents", async () => {
hoisted.state.cfg = {
...hoisted.state.cfg,
acp: {
enabled: true,
backend: "acpx",
allowedAgents: ["claudecode"],
},
};
const result = await spawnAcpDirect(
{
task: "hello",
agentId: "codex",
},
{
agentSessionKey: "agent:main:main",
},
);
expect(result).toMatchObject({
status: "forbidden",
});
});
it("requires an explicit ACP agent when no config default exists", async () => {
const result = await spawnAcpDirect(
{
task: "hello",
},
{
agentSessionKey: "agent:main:main",
},
);
expect(result.status).toBe("error");
expect(result.error).toContain("set `acp.defaultAgent`");
});
it("fails fast when Discord ACP thread spawn is disabled", async () => {
hoisted.state.cfg = {
...hoisted.state.cfg,
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
},
},
},
};
const result = await spawnAcpDirect(
{
task: "hello",
agentId: "codex",
thread: true,
mode: "session",
},
{
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:parent-channel",
},
);
expect(result.status).toBe("error");
expect(result.error).toContain("spawnAcpSessions=true");
});
});

424
src/agents/acp-spawn.ts Normal file
View File

@@ -0,0 +1,424 @@
import crypto from "node:crypto";
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import {
cleanupFailedAcpSpawn,
type AcpSpawnRuntimeCloseHandle,
} from "../acp/control-plane/spawn.js";
import { isAcpEnabledByPolicy, resolveAcpAgentPolicyError } from "../acp/policy.js";
import {
resolveAcpSessionCwd,
resolveAcpThreadSessionDetailLines,
} from "../acp/runtime/session-identifiers.js";
import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../channels/thread-bindings-messages.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSessionTtlMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
import {
getSessionBindingService,
isSessionBindingError,
type SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
export const ACP_SPAWN_MODES = ["run", "session"] as const;
export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number];
export type SpawnAcpParams = {
task: string;
label?: string;
agentId?: string;
cwd?: string;
mode?: SpawnAcpMode;
thread?: boolean;
};
export type SpawnAcpContext = {
agentSessionKey?: string;
agentChannel?: string;
agentAccountId?: string;
agentTo?: string;
agentThreadId?: string | number;
};
export type SpawnAcpResult = {
status: "accepted" | "forbidden" | "error";
childSessionKey?: string;
runId?: string;
mode?: SpawnAcpMode;
note?: string;
error?: string;
};
export const ACP_SPAWN_ACCEPTED_NOTE =
"initial ACP task queued in isolated session; follow-ups continue in the bound thread.";
export const ACP_SPAWN_SESSION_ACCEPTED_NOTE =
"thread-bound ACP session stays active after this task; continue in-thread for follow-ups.";
type PreparedAcpThreadBinding = {
channel: string;
accountId: string;
conversationId: string;
};
function resolveSpawnMode(params: {
requestedMode?: SpawnAcpMode;
threadRequested: boolean;
}): SpawnAcpMode {
if (params.requestedMode === "run" || params.requestedMode === "session") {
return params.requestedMode;
}
// Thread-bound spawns should default to persistent sessions.
return params.threadRequested ? "session" : "run";
}
function resolveAcpSessionMode(mode: SpawnAcpMode): AcpRuntimeSessionMode {
return mode === "session" ? "persistent" : "oneshot";
}
function resolveTargetAcpAgentId(params: {
requestedAgentId?: string;
cfg: OpenClawConfig;
}): { ok: true; agentId: string } | { ok: false; error: string } {
const requested = normalizeOptionalAgentId(params.requestedAgentId);
if (requested) {
return { ok: true, agentId: requested };
}
const configuredDefault = normalizeOptionalAgentId(params.cfg.acp?.defaultAgent);
if (configuredDefault) {
return { ok: true, agentId: configuredDefault };
}
return {
ok: false,
error:
"ACP target agent is not configured. Pass `agentId` in `sessions_spawn` or set `acp.defaultAgent` in config.",
};
}
function normalizeOptionalAgentId(value: string | undefined | null): string | undefined {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return undefined;
}
return normalizeAgentId(trimmed);
}
function summarizeError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
return "error";
}
function resolveConversationIdForThreadBinding(params: {
to?: string;
threadId?: string | number;
}): string | undefined {
return resolveConversationIdFromTargets({
threadId: params.threadId,
targets: [params.to],
});
}
function prepareAcpThreadBinding(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
}): { ok: true; binding: PreparedAcpThreadBinding } | { ok: false; error: string } {
const channel = params.channel?.trim().toLowerCase();
if (!channel) {
return {
ok: false,
error: "thread=true for ACP sessions requires a channel context.",
};
}
const accountId = params.accountId?.trim() || "default";
const policy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel,
accountId,
kind: "acp",
});
if (!policy.enabled) {
return {
ok: false,
error: formatThreadBindingDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "acp",
}),
};
}
if (!policy.spawnEnabled) {
return {
ok: false,
error: formatThreadBindingSpawnDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "acp",
}),
};
}
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: policy.channel,
accountId: policy.accountId,
});
if (!capabilities.adapterAvailable) {
return {
ok: false,
error: `Thread bindings are unavailable for ${policy.channel}.`,
};
}
if (!capabilities.bindSupported || !capabilities.placements.includes("child")) {
return {
ok: false,
error: `Thread bindings do not support ACP thread spawn for ${policy.channel}.`,
};
}
const conversationId = resolveConversationIdForThreadBinding({
to: params.to,
threadId: params.threadId,
});
if (!conversationId) {
return {
ok: false,
error: `Could not resolve a ${policy.channel} conversation for ACP thread spawn.`,
};
}
return {
ok: true,
binding: {
channel: policy.channel,
accountId: policy.accountId,
conversationId,
},
};
}
export async function spawnAcpDirect(
params: SpawnAcpParams,
ctx: SpawnAcpContext,
): Promise<SpawnAcpResult> {
const cfg = loadConfig();
if (!isAcpEnabledByPolicy(cfg)) {
return {
status: "forbidden",
error: "ACP is disabled by policy (`acp.enabled=false`).",
};
}
const requestThreadBinding = params.thread === true;
const spawnMode = resolveSpawnMode({
requestedMode: params.mode,
threadRequested: requestThreadBinding,
});
if (spawnMode === "session" && !requestThreadBinding) {
return {
status: "error",
error: 'mode="session" requires thread=true so the ACP session can stay bound to a thread.',
};
}
const targetAgentResult = resolveTargetAcpAgentId({
requestedAgentId: params.agentId,
cfg,
});
if (!targetAgentResult.ok) {
return {
status: "error",
error: targetAgentResult.error,
};
}
const targetAgentId = targetAgentResult.agentId;
const agentPolicyError = resolveAcpAgentPolicyError(cfg, targetAgentId);
if (agentPolicyError) {
return {
status: "forbidden",
error: agentPolicyError.message,
};
}
const sessionKey = `agent:${targetAgentId}:acp:${crypto.randomUUID()}`;
const runtimeMode = resolveAcpSessionMode(spawnMode);
let preparedBinding: PreparedAcpThreadBinding | null = null;
if (requestThreadBinding) {
const prepared = prepareAcpThreadBinding({
cfg,
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
to: ctx.agentTo,
threadId: ctx.agentThreadId,
});
if (!prepared.ok) {
return {
status: "error",
error: prepared.error,
};
}
preparedBinding = prepared.binding;
}
const acpManager = getAcpSessionManager();
const bindingService = getSessionBindingService();
let binding: SessionBindingRecord | null = null;
let sessionCreated = false;
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
try {
await callGateway({
method: "sessions.patch",
params: {
key: sessionKey,
...(params.label ? { label: params.label } : {}),
},
timeoutMs: 10_000,
});
sessionCreated = true;
const initialized = await acpManager.initializeSession({
cfg,
sessionKey,
agent: targetAgentId,
mode: runtimeMode,
cwd: params.cwd,
backendId: cfg.acp?.backend,
});
initializedRuntime = {
runtime: initialized.runtime,
handle: initialized.handle,
};
if (preparedBinding) {
binding = await bindingService.bind({
targetSessionKey: sessionKey,
targetKind: "session",
conversation: {
channel: preparedBinding.channel,
accountId: preparedBinding.accountId,
conversationId: preparedBinding.conversationId,
},
placement: "child",
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: targetAgentId,
label: params.label || targetAgentId,
}),
agentId: targetAgentId,
label: params.label || undefined,
boundBy: "system",
introText: resolveThreadBindingIntroText({
agentId: targetAgentId,
label: params.label || undefined,
sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({
cfg,
channel: preparedBinding.channel,
accountId: preparedBinding.accountId,
}),
sessionCwd: resolveAcpSessionCwd(initialized.meta),
sessionDetails: resolveAcpThreadSessionDetailLines({
sessionKey,
meta: initialized.meta,
}),
}),
},
});
if (!binding?.conversation.conversationId) {
throw new Error(
`Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`,
);
}
}
} catch (err) {
await cleanupFailedAcpSpawn({
cfg,
sessionKey,
shouldDeleteSession: sessionCreated,
deleteTranscript: true,
runtimeCloseHandle: initializedRuntime,
});
return {
status: "error",
error: isSessionBindingError(err) ? err.message : summarizeError(err),
};
}
const requesterOrigin = normalizeDeliveryContext({
channel: ctx.agentChannel,
accountId: ctx.agentAccountId,
to: ctx.agentTo,
threadId: ctx.agentThreadId,
});
// For thread-bound ACP spawns, force bootstrap delivery to the new child thread.
const boundThreadIdRaw = binding?.conversation.conversationId;
const boundThreadId = boundThreadIdRaw ? String(boundThreadIdRaw).trim() || undefined : undefined;
const fallbackThreadIdRaw = requesterOrigin?.threadId;
const fallbackThreadId =
fallbackThreadIdRaw != null ? String(fallbackThreadIdRaw).trim() || undefined : undefined;
const deliveryThreadId = boundThreadId ?? fallbackThreadId;
const inferredDeliveryTo = boundThreadId
? `channel:${boundThreadId}`
: requesterOrigin?.to?.trim() || (deliveryThreadId ? `channel:${deliveryThreadId}` : undefined);
const hasDeliveryTarget = Boolean(requesterOrigin?.channel && inferredDeliveryTo);
const childIdem = crypto.randomUUID();
let childRunId: string = childIdem;
try {
const response = await callGateway<{ runId?: string }>({
method: "agent",
params: {
message: params.task,
sessionKey,
channel: hasDeliveryTarget ? requesterOrigin?.channel : undefined,
to: hasDeliveryTarget ? inferredDeliveryTo : undefined,
accountId: hasDeliveryTarget ? (requesterOrigin?.accountId ?? undefined) : undefined,
threadId: hasDeliveryTarget ? deliveryThreadId : undefined,
idempotencyKey: childIdem,
deliver: hasDeliveryTarget,
label: params.label || undefined,
},
timeoutMs: 10_000,
});
if (typeof response?.runId === "string" && response.runId.trim()) {
childRunId = response.runId.trim();
}
} catch (err) {
await cleanupFailedAcpSpawn({
cfg,
sessionKey,
shouldDeleteSession: true,
deleteTranscript: true,
});
return {
status: "error",
error: summarizeError(err),
childSessionKey: sessionKey,
};
}
return {
status: "accepted",
childSessionKey: sessionKey,
runId: childRunId,
mode: spawnMode,
note: spawnMode === "session" ? ACP_SPAWN_SESSION_ACCEPTED_NOTE : ACP_SPAWN_ACCEPTED_NOTE,
};
}

View File

@@ -93,6 +93,7 @@ export function buildSystemPrompt(params: {
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
docsPath: params.docsPath,
acpEnabled: params.config?.acp?.enabled !== false,
runtimeInfo,
toolNames: params.tools.map((tool) => tool.name),
modelAliasLines: buildModelAliasLines(params.config),

View File

@@ -91,6 +91,8 @@ describe("sessions tools", () => {
expect(schemaProp("sessions_spawn", "runTimeoutSeconds").type).toBe("number");
expect(schemaProp("sessions_spawn", "thread").type).toBe("boolean");
expect(schemaProp("sessions_spawn", "mode").type).toBe("string");
expect(schemaProp("sessions_spawn", "runtime").type).toBe("string");
expect(schemaProp("sessions_spawn", "cwd").type).toBe("string");
expect(schemaProp("subagents", "recentMinutes").type).toBe("number");
});

View File

@@ -499,6 +499,7 @@ export async function compactEmbeddedPiSessionDirect(
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
acpEnabled: params.config?.acp?.enabled !== false,
runtimeInfo,
reactionGuidance,
messageToolHints,

View File

@@ -545,6 +545,7 @@ export async function runEmbeddedAttempt(
workspaceNotes,
reactionGuidance,
promptMode,
acpEnabled: params.config?.acp?.enabled !== false,
runtimeInfo,
messageToolHints,
sandboxInfo,

View File

@@ -28,6 +28,8 @@ export function buildEmbeddedSystemPrompt(params: {
workspaceNotes?: string[];
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
acpEnabled?: boolean;
runtimeInfo: {
agentId?: string;
host: string;
@@ -67,6 +69,7 @@ export function buildEmbeddedSystemPrompt(params: {
workspaceNotes: params.workspaceNotes,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
acpEnabled: params.acpEnabled,
runtimeInfo: params.runtimeInfo,
messageToolHints: params.messageToolHints,
sandboxInfo: params.sandboxInfo,

View File

@@ -0,0 +1,103 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
const hoisted = vi.hoisted(() => ({
loadPluginManifestRegistry: vi.fn(),
}));
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args),
}));
const { resolvePluginSkillDirs } = await import("./plugin-skills.js");
const tempDirs = createTrackedTempDirs();
function buildRegistry(params: { acpxRoot: string; helperRoot: string }): PluginManifestRegistry {
return {
diagnostics: [],
plugins: [
{
id: "acpx",
name: "ACPX Runtime",
channels: [],
providers: [],
skills: ["./skills"],
origin: "workspace",
rootDir: params.acpxRoot,
source: params.acpxRoot,
manifestPath: path.join(params.acpxRoot, "openclaw.plugin.json"),
},
{
id: "helper",
name: "Helper",
channels: [],
providers: [],
skills: ["./skills"],
origin: "workspace",
rootDir: params.helperRoot,
source: params.helperRoot,
manifestPath: path.join(params.helperRoot, "openclaw.plugin.json"),
},
],
};
}
afterEach(async () => {
hoisted.loadPluginManifestRegistry.mockReset();
await tempDirs.cleanup();
});
describe("resolvePluginSkillDirs", () => {
it("keeps acpx plugin skills when ACP is enabled", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({
acpxRoot,
helperRoot,
}),
);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {
acp: { enabled: true },
} as OpenClawConfig,
});
expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]);
});
it("skips acpx plugin skills when ACP is disabled", async () => {
const workspaceDir = await tempDirs.make("openclaw-");
const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-");
const helperRoot = await tempDirs.make("openclaw-helper-plugin-");
await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true });
await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true });
hoisted.loadPluginManifestRegistry.mockReturnValue(
buildRegistry({
acpxRoot,
helperRoot,
}),
);
const dirs = resolvePluginSkillDirs({
workspaceDir,
config: {
acp: { enabled: false },
} as OpenClawConfig,
});
expect(dirs).toEqual([path.resolve(helperRoot, "skills")]);
});
});

View File

@@ -27,6 +27,7 @@ export function resolvePluginSkillDirs(params: {
return [];
}
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
const acpEnabled = params.config?.acp?.enabled !== false;
const memorySlot = normalizedPlugins.slots.memory;
let selectedMemoryPluginId: string | null = null;
const seen = new Set<string>();
@@ -45,6 +46,10 @@ export function resolvePluginSkillDirs(params: {
if (!enableState.enabled) {
continue;
}
// ACP router skills should not be attached when ACP is explicitly disabled.
if (!acpEnabled && record.id === "acpx") {
continue;
}
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,

View File

@@ -924,6 +924,8 @@ export function buildSubagentSystemPrompt(params: {
childSessionKey: string;
label?: string;
task?: string;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
acpEnabled?: boolean;
/** Depth of the child being spawned (1 = sub-agent, 2 = sub-sub-agent). */
childDepth?: number;
/** Config value: max allowed spawn depth. */
@@ -938,6 +940,7 @@ export function buildSubagentSystemPrompt(params: {
typeof params.maxSpawnDepth === "number"
? params.maxSpawnDepth
: DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH;
const acpEnabled = params.acpEnabled !== false;
const canSpawn = childDepth < maxSpawnDepth;
const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent";
@@ -983,6 +986,17 @@ export function buildSubagentSystemPrompt(params: {
"Default workflow: spawn work, continue orchestrating, and wait for auto-announced completions.",
"Do NOT repeatedly poll `subagents list` in a loop unless you are actively debugging or intervening.",
"Coordinate their work and synthesize results before reporting back.",
...(acpEnabled
? [
'For ACP harness sessions (codex/claudecode/gemini), use `sessions_spawn` with `runtime: "acp"` (set `agentId` unless `acp.defaultAgent` is configured).',
'`agents_list` and `subagents` apply to OpenClaw sub-agents (`runtime: "subagent"`); ACP harness ids are controlled by `acp.allowedAgents`.',
"Do not ask users to run slash commands or CLI when `sessions_spawn` can do it directly.",
"Do not use `exec` (`openclaw ...`, `acpx ...`) to spawn ACP sessions.",
'Use `subagents` only for OpenClaw subagents (`runtime: "subagent"`).',
"Subagent results auto-announce back to you; ACP sessions continue in their bound thread.",
"Avoid polling loops; spawn, orchestrate, and synthesize results.",
]
: []),
"",
);
} else if (childDepth >= 2) {

View File

@@ -0,0 +1,157 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const noop = () => {};
let lifecycleHandler:
| ((evt: {
stream?: string;
runId: string;
data?: {
phase?: string;
startedAt?: number;
endedAt?: number;
aborted?: boolean;
error?: string;
};
}) => void)
| undefined;
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (request: unknown) => {
const method = (request as { method?: string }).method;
if (method === "agent.wait") {
// Keep wait unresolved from the RPC path so lifecycle fallback logic is exercised.
return { status: "pending" };
}
return {};
}),
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: vi.fn((handler: typeof lifecycleHandler) => {
lifecycleHandler = handler;
return noop;
}),
}));
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(() => ({
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
})),
}));
const announceSpy = vi.fn(async () => true);
vi.mock("./subagent-announce.js", () => ({
runSubagentAnnounceFlow: announceSpy,
}));
vi.mock("../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(() => null),
}));
vi.mock("./subagent-registry.store.js", () => ({
loadSubagentRegistryFromDisk: vi.fn(() => new Map()),
saveSubagentRegistryToDisk: vi.fn(() => {}),
}));
describe("subagent registry lifecycle error grace", () => {
let mod: typeof import("./subagent-registry.js");
beforeAll(async () => {
mod = await import("./subagent-registry.js");
});
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
announceSpy.mockClear();
lifecycleHandler = undefined;
mod.resetSubagentRegistryForTests({ persist: false });
vi.useRealTimers();
});
const flushAsync = async () => {
await Promise.resolve();
await Promise.resolve();
};
it("ignores transient lifecycle errors when run retries and then ends successfully", async () => {
mod.registerSubagentRun({
runId: "run-transient-error",
childSessionKey: "agent:main:subagent:transient-error",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "transient error test",
cleanup: "keep",
expectsCompletionMessage: true,
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-transient-error",
data: { phase: "error", error: "rate limit", endedAt: 1_000 },
});
await flushAsync();
expect(announceSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(14_999);
expect(announceSpy).not.toHaveBeenCalled();
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-transient-error",
data: { phase: "start", startedAt: 1_050 },
});
await flushAsync();
await vi.advanceTimersByTimeAsync(20_000);
expect(announceSpy).not.toHaveBeenCalled();
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-transient-error",
data: { phase: "end", endedAt: 1_250 },
});
await flushAsync();
expect(announceSpy).toHaveBeenCalledTimes(1);
const announceCalls = announceSpy.mock.calls as unknown as Array<Array<unknown>>;
const first = (announceCalls[0]?.[0] ?? {}) as {
outcome?: { status?: string; error?: string };
};
expect(first.outcome?.status).toBe("ok");
});
it("announces error when lifecycle error remains terminal after grace window", async () => {
mod.registerSubagentRun({
runId: "run-terminal-error",
childSessionKey: "agent:main:subagent:terminal-error",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "terminal error test",
cleanup: "keep",
expectsCompletionMessage: true,
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-terminal-error",
data: { phase: "error", error: "fatal failure", endedAt: 2_000 },
});
await flushAsync();
expect(announceSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(15_000);
await flushAsync();
expect(announceSpy).toHaveBeenCalledTimes(1);
const announceCalls = announceSpy.mock.calls as unknown as Array<Array<unknown>>;
const first = (announceCalls[0]?.[0] ?? {}) as {
outcome?: { status?: string; error?: string };
};
expect(first.outcome?.status).toBe("error");
expect(first.outcome?.error).toBe("fatal failure");
});
});

View File

@@ -66,6 +66,12 @@ const MAX_ANNOUNCE_RETRY_COUNT = 3;
*/
const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes
type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id";
/**
* Embedded runs can emit transient lifecycle `error` events while provider/model
* retry is still in progress. Defer terminal error cleanup briefly so a
* subsequent lifecycle `start` / `end` can cancel premature failure announces.
*/
const LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000;
function resolveAnnounceRetryDelayMs(retryCount: number) {
const boundedRetryCount = Math.max(0, Math.min(retryCount, 10));
@@ -204,6 +210,66 @@ function reconcileOrphanedRestoredRuns() {
const resumedRuns = new Set<string>();
const endedHookInFlightRunIds = new Set<string>();
const pendingLifecycleErrorByRunId = new Map<
string,
{
timer: NodeJS.Timeout;
endedAt: number;
error?: string;
}
>();
function clearPendingLifecycleError(runId: string) {
const pending = pendingLifecycleErrorByRunId.get(runId);
if (!pending) {
return;
}
clearTimeout(pending.timer);
pendingLifecycleErrorByRunId.delete(runId);
}
function clearAllPendingLifecycleErrors() {
for (const pending of pendingLifecycleErrorByRunId.values()) {
clearTimeout(pending.timer);
}
pendingLifecycleErrorByRunId.clear();
}
function schedulePendingLifecycleError(params: { runId: string; endedAt: number; error?: string }) {
clearPendingLifecycleError(params.runId);
const timer = setTimeout(() => {
const pending = pendingLifecycleErrorByRunId.get(params.runId);
if (!pending || pending.timer !== timer) {
return;
}
pendingLifecycleErrorByRunId.delete(params.runId);
const entry = subagentRuns.get(params.runId);
if (!entry) {
return;
}
if (entry.endedReason === SUBAGENT_ENDED_REASON_COMPLETE || entry.outcome?.status === "ok") {
return;
}
void completeSubagentRun({
runId: params.runId,
endedAt: pending.endedAt,
outcome: {
status: "error",
error: pending.error,
},
reason: SUBAGENT_ENDED_REASON_ERROR,
sendFarewell: true,
accountId: entry.requesterOrigin?.accountId,
triggerCleanup: true,
});
}, LIFECYCLE_ERROR_RETRY_GRACE_MS);
timer.unref?.();
pendingLifecycleErrorByRunId.set(params.runId, {
timer,
endedAt: params.endedAt,
error: params.error,
});
}
function suppressAnnounceForSteerRestart(entry?: SubagentRunRecord) {
return entry?.suppressAnnounceReason === "steer-restart";
@@ -256,6 +322,7 @@ async function completeSubagentRun(params: {
accountId?: string;
triggerCleanup: boolean;
}) {
clearPendingLifecycleError(params.runId);
const entry = subagentRuns.get(params.runId);
if (!entry) {
return;
@@ -491,6 +558,7 @@ async function sweepSubagentRuns() {
if (!entry.archiveAtMs || entry.archiveAtMs > now) {
continue;
}
clearPendingLifecycleError(runId);
subagentRuns.delete(runId);
mutated = true;
try {
@@ -531,6 +599,7 @@ function ensureListener() {
}
const phase = evt.data?.phase;
if (phase === "start") {
clearPendingLifecycleError(evt.runId);
const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined;
if (startedAt) {
entry.startedAt = startedAt;
@@ -543,17 +612,23 @@ function ensureListener() {
}
const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : Date.now();
const error = typeof evt.data?.error === "string" ? evt.data.error : undefined;
const outcome: SubagentRunOutcome =
phase === "error"
? { status: "error", error }
: evt.data?.aborted
? { status: "timeout" }
: { status: "ok" };
if (phase === "error") {
schedulePendingLifecycleError({
runId: evt.runId,
endedAt,
error,
});
return;
}
clearPendingLifecycleError(evt.runId);
const outcome: SubagentRunOutcome = evt.data?.aborted
? { status: "timeout" }
: { status: "ok" };
await completeSubagentRun({
runId: evt.runId,
endedAt,
outcome,
reason: phase === "error" ? SUBAGENT_ENDED_REASON_ERROR : SUBAGENT_ENDED_REASON_COMPLETE,
reason: SUBAGENT_ENDED_REASON_COMPLETE,
sendFarewell: true,
accountId: entry.requesterOrigin?.accountId,
triggerCleanup: true,
@@ -661,6 +736,7 @@ function completeCleanupBookkeeping(params: {
completedAt: number;
}) {
if (params.cleanup === "delete") {
clearPendingLifecycleError(params.runId);
subagentRuns.delete(params.runId);
persistSubagentRuns();
retryDeferredCompletedAnnounces(params.runId);
@@ -774,6 +850,7 @@ export function replaceSubagentRunAfterSteer(params: {
}
if (previousRunId !== nextRunId) {
clearPendingLifecycleError(previousRunId);
subagentRuns.delete(previousRunId);
resumedRuns.delete(previousRunId);
}
@@ -935,6 +1012,7 @@ export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
subagentRuns.clear();
resumedRuns.clear();
endedHookInFlightRunIds.clear();
clearAllPendingLifecycleErrors();
resetAnnounceQueuesForTests();
stopSweeper();
restoreAttempted = false;
@@ -953,6 +1031,7 @@ export function addSubagentRunForTests(entry: SubagentRunRecord) {
}
export function releaseSubagentRun(runId: string) {
clearPendingLifecycleError(runId);
const didDelete = subagentRuns.delete(runId);
if (didDelete) {
persistSubagentRuns();
@@ -1020,6 +1099,7 @@ export function markSubagentRunTerminated(params: {
let updated = 0;
const entriesByChildSessionKey = new Map<string, SubagentRunRecord>();
for (const runId of runIds) {
clearPendingLifecycleError(runId);
const entry = subagentRuns.get(runId);
if (!entry) {
continue;

View File

@@ -385,6 +385,7 @@ export async function spawnSubagentDirect(
childSessionKey,
label: label || undefined,
task,
acpEnabled: cfg.acp?.enabled !== false,
childDepth,
maxSpawnDepth,
});

View File

@@ -221,6 +221,9 @@ describe("buildAgentSystemPrompt", () => {
);
expect(prompt).toContain("Completion is push-based: it will auto-announce when done.");
expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop");
expect(prompt).toContain(
"When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.",
);
});
it("lists available tools when provided", () => {
@@ -235,6 +238,52 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("sessions_send");
});
it("documents ACP sessions_spawn agent targeting requirements", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn"],
});
expect(prompt).toContain("sessions_spawn");
expect(prompt).toContain(
'runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured',
);
expect(prompt).toContain("not agents_list");
});
it("guides harness requests to ACP thread-bound spawns", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn", "subagents", "agents_list", "exec"],
});
expect(prompt).toContain(
'For requests like "do this in codex/claude code/gemini", treat it as ACP harness intent',
);
expect(prompt).toContain(
'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`)',
);
expect(prompt).toContain(
"do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows",
);
});
it("omits ACP harness guidance when ACP is disabled", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
toolNames: ["sessions_spawn", "subagents", "agents_list", "exec"],
acpEnabled: false,
});
expect(prompt).not.toContain(
'For requests like "do this in codex/claude code/gemini", treat it as ACP harness intent',
);
expect(prompt).not.toContain('runtime="acp" requires `agentId`');
expect(prompt).not.toContain("not ACP harness ids");
expect(prompt).toContain("- sessions_spawn: Spawn an isolated sub-agent session");
expect(prompt).toContain("- agents_list: List OpenClaw agent ids allowed for sessions_spawn");
});
it("preserves tool casing in the prompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
@@ -599,11 +648,18 @@ describe("buildSubagentSystemPrompt", () => {
});
expect(prompt).toContain("## Sub-Agent Spawning");
expect(prompt).toContain("You CAN spawn your own sub-agents");
expect(prompt).toContain(
"You CAN spawn your own sub-agents for parallel or complex work using `sessions_spawn`.",
);
expect(prompt).toContain("sessions_spawn");
expect(prompt).toContain("`subagents` tool");
expect(prompt).toContain("announce their results back to you automatically");
expect(prompt).toContain("Do NOT repeatedly poll `subagents list`");
expect(prompt).toContain('runtime: "acp"');
expect(prompt).toContain("For ACP harness sessions (codex/claudecode/gemini)");
expect(prompt).toContain("set `agentId` unless `acp.defaultAgent` is configured");
expect(prompt).toContain("Do not ask users to run slash commands or CLI");
expect(prompt).toContain("Do not use `exec` (`openclaw ...`, `acpx ...`)");
expect(prompt).toContain("Use `subagents` only for OpenClaw subagents");
expect(prompt).toContain("Subagent results auto-announce back to you");
expect(prompt).toContain("Avoid polling loops");
expect(prompt).toContain("spawned by the main agent");
expect(prompt).toContain("reported to the main agent");
expect(prompt).toContain("[compacted: tool output removed to free context]");
@@ -612,6 +668,21 @@ describe("buildSubagentSystemPrompt", () => {
expect(prompt).toContain("instead of full-file `cat`");
});
it("omits ACP spawning guidance when ACP is disabled", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "research task",
childDepth: 1,
maxSpawnDepth: 2,
acpEnabled: false,
});
expect(prompt).not.toContain('runtime: "acp"');
expect(prompt).not.toContain("For ACP harness sessions (codex/claudecode/gemini)");
expect(prompt).not.toContain("set `agentId` unless `acp.defaultAgent` is configured");
expect(prompt).toContain("You CAN spawn your own sub-agents");
});
it("renders depth-2 leaf guidance with parent orchestrator labels", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc:subagent:def",

View File

@@ -209,6 +209,8 @@ export function buildAgentSystemPrompt(params: {
ttsHint?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
acpEnabled?: boolean;
runtimeInfo?: {
agentId?: string;
host?: string;
@@ -231,6 +233,7 @@ export function buildAgentSystemPrompt(params: {
};
memoryCitationsMode?: MemoryCitationsMode;
}) {
const acpEnabled = params.acpEnabled !== false;
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
write: "Create or overwrite files",
@@ -250,11 +253,15 @@ export function buildAgentSystemPrompt(params: {
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running OpenClaw process",
agents_list: "List agent ids allowed for sessions_spawn",
agents_list: acpEnabled
? 'List OpenClaw agent ids allowed for sessions_spawn when runtime="subagent" (not ACP harness ids)'
: "List OpenClaw agent ids allowed for sessions_spawn",
sessions_list: "List other sessions (incl. sub-agents) with filters/last",
sessions_history: "Fetch history for another session/sub-agent",
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
sessions_spawn: acpEnabled
? 'Spawn an isolated sub-agent or ACP coding session (runtime="acp" requires `agentId` unless `acp.defaultAgent` is configured; ACP harness ids follow acp.allowedAgents, not agents_list)'
: "Spawn an isolated sub-agent session",
subagents: "List, steer, or kill sub-agent runs for this requester session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
@@ -303,6 +310,7 @@ export function buildAgentSystemPrompt(params: {
const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase());
const availableTools = new Set(normalizedTools);
const hasSessionsSpawn = availableTools.has("sessions_spawn");
const externalToolSummaries = new Map<string, string>();
for (const [key, value] of Object.entries(params.toolSummaries ?? {})) {
const normalized = key.trim().toLowerCase();
@@ -436,6 +444,13 @@ export function buildAgentSystemPrompt(params: {
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
`For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
...(hasSessionsSpawn && acpEnabled
? [
'For requests like "do this in codex/claude code/gemini", treat it as ACP harness intent and call `sessions_spawn` with `runtime: "acp"`.',
'On Discord, default ACP harness requests to thread-bound persistent sessions (`thread: true`, `mode: "session"`) unless the user asks otherwise.',
"Set `agentId` explicitly unless `acp.defaultAgent` is configured, and do not route ACP harness requests through `subagents`/`agents_list` or local PTY exec flows.",
]
: []),
"Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).",
"",
"## Tool Call Style",
@@ -443,6 +458,7 @@ export function buildAgentSystemPrompt(params: {
"Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.",
"Keep narration brief and value-dense; avoid repeating obvious steps.",
"Use plain human language for narration unless in a technical context.",
"When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI or slash commands.",
"",
...safetySection,
"## OpenClaw CLI Quick Reference",

View File

@@ -26,7 +26,8 @@ export function createAgentsListTool(opts?: {
return {
label: "Agents",
name: "agents_list",
description: "List agent ids you can target with sessions_spawn (based on allowlists).",
description:
'List OpenClaw agent ids you can target with `sessions_spawn` when `runtime="subagent"` (based on subagent allowlists).',
parameters: AgentsListToolSchema,
execute: async () => {
const cfg = loadConfig();

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn();
const spawnAcpDirectMock = vi.fn();
return {
spawnSubagentDirectMock,
spawnAcpDirectMock,
};
});
vi.mock("../subagent-spawn.js", () => ({
SUBAGENT_SPAWN_MODES: ["run", "session"],
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
}));
vi.mock("../acp-spawn.js", () => ({
ACP_SPAWN_MODES: ["run", "session"],
spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args),
}));
const { createSessionsSpawnTool } = await import("./sessions-spawn-tool.js");
describe("sessions_spawn tool", () => {
beforeEach(() => {
hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({
status: "accepted",
childSessionKey: "agent:main:subagent:1",
runId: "run-subagent",
});
hoisted.spawnAcpDirectMock.mockReset().mockResolvedValue({
status: "accepted",
childSessionKey: "agent:codex:acp:1",
runId: "run-acp",
});
});
it("uses subagent runtime by default", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:123",
agentThreadId: "456",
});
const result = await tool.execute("call-1", {
task: "build feature",
agentId: "main",
model: "anthropic/claude-sonnet-4-6",
thinking: "medium",
runTimeoutSeconds: 5,
thread: true,
mode: "session",
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:main:subagent:1",
runId: "run-subagent",
});
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "build feature",
agentId: "main",
model: "anthropic/claude-sonnet-4-6",
thinking: "medium",
runTimeoutSeconds: 5,
thread: true,
mode: "session",
cleanup: "keep",
}),
expect.objectContaining({
agentSessionKey: "agent:main:main",
}),
);
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
});
it("routes to ACP runtime when runtime=acp", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
agentChannel: "discord",
agentAccountId: "default",
agentTo: "channel:123",
agentThreadId: "456",
});
const result = await tool.execute("call-2", {
runtime: "acp",
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
thread: true,
mode: "session",
});
expect(result.details).toMatchObject({
status: "accepted",
childSessionKey: "agent:codex:acp:1",
runId: "run-acp",
});
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
thread: true,
mode: "session",
}),
expect.objectContaining({
agentSessionKey: "agent:main:main",
}),
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,21 @@
import { Type } from "@sinclair/typebox";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { ACP_SPAWN_MODES, spawnAcpDirect } from "../acp-spawn.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { SUBAGENT_SPAWN_MODES, spawnSubagentDirect } from "../subagent-spawn.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
const SESSIONS_SPAWN_RUNTIMES = ["subagent", "acp"] as const;
const SessionsSpawnToolSchema = Type.Object({
task: Type.String(),
label: Type.Optional(Type.String()),
runtime: optionalStringEnum(SESSIONS_SPAWN_RUNTIMES),
agentId: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
cwd: Type.Optional(Type.String()),
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
// Back-compat: older callers used timeoutSeconds for this tool.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
@@ -36,15 +41,17 @@ export function createSessionsSpawnTool(opts?: {
label: "Sessions",
name: "sessions_spawn",
description:
'Spawn a sub-agent in an isolated session (mode="run" one-shot or mode="session" persistent) and route results back to the requester chat/thread.',
'Spawn an isolated session (runtime="subagent" or runtime="acp"). mode="run" is one-shot and mode="session" is persistent/thread-bound.',
parameters: SessionsSpawnToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const task = readStringParam(params, "task", { required: true });
const label = typeof params.label === "string" ? params.label.trim() : "";
const runtime = params.runtime === "acp" ? "acp" : "subagent";
const requestedAgentId = readStringParam(params, "agentId");
const modelOverride = readStringParam(params, "model");
const thinkingOverrideRaw = readStringParam(params, "thinking");
const cwd = readStringParam(params, "cwd");
const mode = params.mode === "run" || params.mode === "session" ? params.mode : undefined;
const cleanup =
params.cleanup === "keep" || params.cleanup === "delete" ? params.cleanup : "keep";
@@ -61,31 +68,50 @@ export function createSessionsSpawnTool(opts?: {
: undefined;
const thread = params.thread === true;
const result = await spawnSubagentDirect(
{
task,
label: label || undefined,
agentId: requestedAgentId,
model: modelOverride,
thinking: thinkingOverrideRaw,
runTimeoutSeconds,
thread,
mode,
cleanup,
expectsCompletionMessage: true,
},
{
agentSessionKey: opts?.agentSessionKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
},
);
const result =
runtime === "acp"
? await spawnAcpDirect(
{
task,
label: label || undefined,
agentId: requestedAgentId,
cwd,
mode: mode && ACP_SPAWN_MODES.includes(mode) ? mode : undefined,
thread,
},
{
agentSessionKey: opts?.agentSessionKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
},
)
: await spawnSubagentDirect(
{
task,
label: label || undefined,
agentId: requestedAgentId,
model: modelOverride,
thinking: thinkingOverrideRaw,
runTimeoutSeconds,
thread,
mode,
cleanup,
expectsCompletionMessage: true,
},
{
agentSessionKey: opts?.agentSessionKey,
agentChannel: opts?.agentChannel,
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
agentGroupId: opts?.agentGroupId,
agentGroupChannel: opts?.agentGroupChannel,
agentGroupSpace: opts?.agentGroupSpace,
requesterAgentIdOverride: opts?.requesterAgentIdOverride,
},
);
return jsonResult(result);
},

View File

@@ -311,6 +311,46 @@ function buildChatCommands(): ChatCommandDefinition[] {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "acp",
nativeName: "acp",
description: "Manage ACP sessions and runtime options.",
textAlias: "/acp",
category: "management",
args: [
{
name: "action",
description:
"spawn | cancel | steer | close | sessions | status | set-mode | set | cwd | permissions | timeout | model | reset-options | doctor | install | help",
type: "string",
choices: [
"spawn",
"cancel",
"steer",
"close",
"sessions",
"status",
"set-mode",
"set",
"cwd",
"permissions",
"timeout",
"model",
"reset-options",
"doctor",
"install",
"help",
],
},
{
name: "value",
description: "Action arguments",
type: "string",
captureRemaining: true,
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "focus",
nativeName: "focus",

View File

@@ -109,6 +109,30 @@ describe("commands registry", () => {
expect(findCommandByNativeName("tts", "discord")).toBeUndefined();
});
it("keeps ACP native action choices aligned with implemented handlers", () => {
const acp = listChatCommands().find((command) => command.key === "acp");
expect(acp).toBeTruthy();
const actionArg = acp?.args?.find((arg) => arg.name === "action");
expect(actionArg?.choices).toEqual([
"spawn",
"cancel",
"steer",
"close",
"sessions",
"status",
"set-mode",
"set",
"cwd",
"permissions",
"timeout",
"model",
"reset-options",
"doctor",
"install",
"help",
]);
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { createAcpReplyProjector } from "./acp-projector.js";
function createCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
return {
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 50,
},
},
...overrides,
} as OpenClawConfig;
}
describe("createAcpReplyProjector", () => {
it("coalesces text deltas into bounded block chunks", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({
type: "text_delta",
text: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
});
await projector.onEvent({
type: "text_delta",
text: "bbbbbbbbbb",
});
await projector.flush(true);
expect(deliveries).toEqual([
{
kind: "block",
text: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
{ kind: "block", text: "aabbbbbbbbbb" },
]);
});
it("buffers tiny token deltas and flushes once at turn end", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
},
},
}),
shouldSendToolSummaries: true,
provider: "discord",
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({ type: "text_delta", text: "What" });
await projector.onEvent({ type: "text_delta", text: " do" });
await projector.onEvent({ type: "text_delta", text: " you want to work on?" });
expect(deliveries).toEqual([]);
await projector.flush(true);
expect(deliveries).toEqual([{ kind: "block", text: "What do you want to work on?" }]);
});
it("filters thought stream text and suppresses tool summaries when disabled", async () => {
const deliver = vi.fn(async () => true);
const projector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: false,
deliver,
});
await projector.onEvent({ type: "text_delta", text: "internal", stream: "thought" });
await projector.onEvent({ type: "status", text: "running tool" });
await projector.onEvent({ type: "tool_call", text: "ls" });
await projector.flush(true);
expect(deliver).not.toHaveBeenCalled();
});
it("emits status and tool_call summaries when enabled", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg(),
shouldSendToolSummaries: true,
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({ type: "status", text: "planning" });
await projector.onEvent({ type: "tool_call", text: "exec ls" });
expect(deliveries).toEqual([
{ kind: "tool", text: "⚙️ planning" },
{ kind: "tool", text: "🧰 exec ls" },
]);
});
it("flushes pending streamed text before tool/status updates", async () => {
const deliveries: Array<{ kind: string; text?: string }> = [];
const projector = createAcpReplyProjector({
cfg: createCfg({
acp: {
enabled: true,
stream: {
coalesceIdleMs: 0,
maxChunkChars: 256,
},
},
}),
shouldSendToolSummaries: true,
provider: "discord",
deliver: async (kind, payload) => {
deliveries.push({ kind, text: payload.text });
return true;
},
});
await projector.onEvent({ type: "text_delta", text: "Hello" });
await projector.onEvent({ type: "text_delta", text: " world" });
await projector.onEvent({ type: "status", text: "running tool" });
expect(deliveries).toEqual([
{ kind: "block", text: "Hello world" },
{ kind: "tool", text: "⚙️ running tool" },
]);
});
});

View File

@@ -0,0 +1,140 @@
import type { AcpRuntimeEvent } from "../../acp/runtime/types.js";
import { EmbeddedBlockChunker } from "../../agents/pi-embedded-block-chunker.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { ReplyPayload } from "../types.js";
import { createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
import type { ReplyDispatchKind } from "./reply-dispatcher.js";
const DEFAULT_ACP_STREAM_COALESCE_IDLE_MS = 350;
const DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS = 1800;
const ACP_BLOCK_REPLY_TIMEOUT_MS = 15_000;
function clampPositiveInteger(
value: unknown,
fallback: number,
bounds: { min: number; max: number },
): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.round(value);
if (rounded < bounds.min) {
return bounds.min;
}
if (rounded > bounds.max) {
return bounds.max;
}
return rounded;
}
function resolveAcpStreamCoalesceIdleMs(cfg: OpenClawConfig): number {
return clampPositiveInteger(
cfg.acp?.stream?.coalesceIdleMs,
DEFAULT_ACP_STREAM_COALESCE_IDLE_MS,
{
min: 0,
max: 5_000,
},
);
}
function resolveAcpStreamMaxChunkChars(cfg: OpenClawConfig): number {
return clampPositiveInteger(cfg.acp?.stream?.maxChunkChars, DEFAULT_ACP_STREAM_MAX_CHUNK_CHARS, {
min: 50,
max: 4_000,
});
}
function resolveAcpStreamingConfig(params: {
cfg: OpenClawConfig;
provider?: string;
accountId?: string;
}) {
return resolveEffectiveBlockStreamingConfig({
cfg: params.cfg,
provider: params.provider,
accountId: params.accountId,
maxChunkChars: resolveAcpStreamMaxChunkChars(params.cfg),
coalesceIdleMs: resolveAcpStreamCoalesceIdleMs(params.cfg),
});
}
export type AcpReplyProjector = {
onEvent: (event: AcpRuntimeEvent) => Promise<void>;
flush: (force?: boolean) => Promise<void>;
};
export function createAcpReplyProjector(params: {
cfg: OpenClawConfig;
shouldSendToolSummaries: boolean;
deliver: (kind: ReplyDispatchKind, payload: ReplyPayload) => Promise<boolean>;
provider?: string;
accountId?: string;
}): AcpReplyProjector {
const streaming = resolveAcpStreamingConfig({
cfg: params.cfg,
provider: params.provider,
accountId: params.accountId,
});
const blockReplyPipeline = createBlockReplyPipeline({
onBlockReply: async (payload) => {
await params.deliver("block", payload);
},
timeoutMs: ACP_BLOCK_REPLY_TIMEOUT_MS,
coalescing: streaming.coalescing,
});
const chunker = new EmbeddedBlockChunker(streaming.chunking);
const drainChunker = (force: boolean) => {
chunker.drain({
force,
emit: (chunk) => {
blockReplyPipeline.enqueue({ text: chunk });
},
});
};
const flush = async (force = false): Promise<void> => {
drainChunker(force);
await blockReplyPipeline.flush({ force });
};
const emitToolSummary = async (prefix: string, text: string): Promise<void> => {
if (!params.shouldSendToolSummaries || !text) {
return;
}
// Keep tool summaries ordered after any pending streamed text.
await flush(true);
await params.deliver("tool", { text: `${prefix} ${text}` });
};
const onEvent = async (event: AcpRuntimeEvent): Promise<void> => {
if (event.type === "text_delta") {
if (event.stream && event.stream !== "output") {
return;
}
if (event.text) {
chunker.append(event.text);
drainChunker(false);
}
return;
}
if (event.type === "status") {
await emitToolSummary("⚙️", event.text);
return;
}
if (event.type === "tool_call") {
await emitToolSummary("🧰", event.text);
return;
}
if (event.type === "done" || event.type === "error") {
await flush(true);
}
};
return {
onEvent,
flush,
};
}

View File

@@ -41,7 +41,7 @@ import { runMemoryFlushIfNeeded } from "./agent-runner-memory.js";
import { buildReplyPayloads } from "./agent-runner-payloads.js";
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.js";
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
import { createFollowupRunner } from "./followup-runner.js";
import { resolveOriginMessageProvider, resolveOriginMessageTo } from "./origin-routing.js";
import {
@@ -195,12 +195,12 @@ export async function runReplyAgent(params: {
const cfg = followupRun.run.config;
const blockReplyCoalescing =
blockStreamingEnabled && opts?.onBlockReply
? resolveBlockStreamingCoalescing(
? resolveEffectiveBlockStreamingConfig({
cfg,
sessionCtx.Provider,
sessionCtx.AccountId,
blockReplyChunking,
)
provider: sessionCtx.Provider,
accountId: sessionCtx.AccountId,
chunking: blockReplyChunking,
}).coalescing
: undefined;
const blockReplyPipeline =
blockStreamingEnabled && opts?.onBlockReply

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
resolveBlockStreamingChunking,
resolveEffectiveBlockStreamingConfig,
} from "./block-streaming.js";
describe("resolveEffectiveBlockStreamingConfig", () => {
it("applies ACP-style overrides while preserving chunk/coalescer bounds", () => {
const cfg = {} as OpenClawConfig;
const baseChunking = resolveBlockStreamingChunking(cfg, "discord");
const resolved = resolveEffectiveBlockStreamingConfig({
cfg,
provider: "discord",
maxChunkChars: 64,
coalesceIdleMs: 25,
});
expect(baseChunking.maxChars).toBeGreaterThanOrEqual(64);
expect(resolved.chunking.maxChars).toBe(64);
expect(resolved.chunking.minChars).toBeLessThanOrEqual(resolved.chunking.maxChars);
expect(resolved.coalescing.maxChars).toBeLessThanOrEqual(resolved.chunking.maxChars);
expect(resolved.coalescing.minChars).toBeLessThanOrEqual(resolved.coalescing.maxChars);
expect(resolved.coalescing.idleMs).toBe(25);
});
it("reuses caller-provided chunking for shared main/subagent/ACP config resolution", () => {
const resolved = resolveEffectiveBlockStreamingConfig({
cfg: undefined,
chunking: {
minChars: 10,
maxChars: 20,
breakPreference: "paragraph",
},
coalesceIdleMs: 0,
});
expect(resolved.chunking).toEqual({
minChars: 10,
maxChars: 20,
breakPreference: "paragraph",
});
expect(resolved.coalescing.maxChars).toBe(20);
expect(resolved.coalescing.idleMs).toBe(0);
});
it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => {
const cfg = {
channels: {
discord: {
textChunkLimit: 4096,
},
},
} as OpenClawConfig;
const baseChunking = resolveBlockStreamingChunking(cfg, "discord");
expect(baseChunking.maxChars).toBeLessThan(1800);
const resolved = resolveEffectiveBlockStreamingConfig({
cfg,
provider: "discord",
maxChunkChars: 1800,
});
expect(resolved.chunking.maxChars).toBe(1800);
expect(resolved.chunking.minChars).toBeLessThanOrEqual(resolved.chunking.maxChars);
});
});

View File

@@ -59,16 +59,101 @@ export type BlockStreamingCoalescing = {
flushOnEnqueue?: boolean;
};
export function resolveBlockStreamingChunking(
cfg: OpenClawConfig | undefined,
provider?: string,
accountId?: string | null,
): {
export type BlockStreamingChunking = {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
flushOnParagraph?: boolean;
};
function clampPositiveInteger(
value: number | undefined,
fallback: number,
bounds: { min: number; max: number },
): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.round(value);
if (rounded < bounds.min) {
return bounds.min;
}
if (rounded > bounds.max) {
return bounds.max;
}
return rounded;
}
export function resolveEffectiveBlockStreamingConfig(params: {
cfg: OpenClawConfig | undefined;
provider?: string;
accountId?: string | null;
chunking?: BlockStreamingChunking;
/** Optional upper bound for chunking/coalescing max chars. */
maxChunkChars?: number;
/** Optional coalescer idle flush override in milliseconds. */
coalesceIdleMs?: number;
}): {
chunking: BlockStreamingChunking;
coalescing: BlockStreamingCoalescing;
} {
const providerKey = normalizeChunkProvider(params.provider);
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
const providerChunkLimit = providerId
? getChannelDock(providerId)?.outbound?.textChunkLimit
: undefined;
const textLimit = resolveTextChunkLimit(params.cfg, providerKey, params.accountId, {
fallbackLimit: providerChunkLimit,
});
const chunkingDefaults =
params.chunking ?? resolveBlockStreamingChunking(params.cfg, params.provider, params.accountId);
const chunkingMax = clampPositiveInteger(params.maxChunkChars, chunkingDefaults.maxChars, {
min: 1,
max: Math.max(1, textLimit),
});
const chunking: BlockStreamingChunking = {
...chunkingDefaults,
minChars: Math.min(chunkingDefaults.minChars, chunkingMax),
maxChars: chunkingMax,
};
const coalescingDefaults = resolveBlockStreamingCoalescing(
params.cfg,
params.provider,
params.accountId,
chunking,
);
const coalescingMax = Math.max(
1,
Math.min(coalescingDefaults?.maxChars ?? chunking.maxChars, chunking.maxChars),
);
const coalescingMin = Math.min(coalescingDefaults?.minChars ?? chunking.minChars, coalescingMax);
const coalescingIdleMs = clampPositiveInteger(
params.coalesceIdleMs,
coalescingDefaults?.idleMs ?? DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS,
{ min: 0, max: 5_000 },
);
const coalescing: BlockStreamingCoalescing = {
minChars: coalescingMin,
maxChars: coalescingMax,
idleMs: coalescingIdleMs,
joiner:
coalescingDefaults?.joiner ??
(chunking.breakPreference === "sentence"
? " "
: chunking.breakPreference === "newline"
? "\n"
: "\n\n"),
flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true,
};
return { chunking, coalescing };
}
export function resolveBlockStreamingChunking(
cfg: OpenClawConfig | undefined,
provider?: string,
accountId?: string | null,
): BlockStreamingChunking {
const providerKey = normalizeChunkProvider(provider);
const providerConfigKey = providerKey;
const providerId = providerKey ? normalizeChannelId(providerKey) : null;

View File

@@ -0,0 +1,796 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
const requireAcpRuntimeBackendMock = vi.fn();
const getAcpRuntimeBackendMock = vi.fn();
const listAcpSessionEntriesMock = vi.fn();
const readAcpSessionEntryMock = vi.fn();
const upsertAcpSessionMetaMock = vi.fn();
const resolveSessionStorePathForAcpMock = vi.fn();
const loadSessionStoreMock = vi.fn();
const sessionBindingCapabilitiesMock = vi.fn();
const sessionBindingBindMock = vi.fn();
const sessionBindingListBySessionMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
const sessionBindingUnbindMock = vi.fn();
const ensureSessionMock = vi.fn();
const runTurnMock = vi.fn();
const cancelMock = vi.fn();
const closeMock = vi.fn();
const getCapabilitiesMock = vi.fn();
const getStatusMock = vi.fn();
const setModeMock = vi.fn();
const setConfigOptionMock = vi.fn();
const doctorMock = vi.fn();
return {
callGatewayMock,
requireAcpRuntimeBackendMock,
getAcpRuntimeBackendMock,
listAcpSessionEntriesMock,
readAcpSessionEntryMock,
upsertAcpSessionMetaMock,
resolveSessionStorePathForAcpMock,
loadSessionStoreMock,
sessionBindingCapabilitiesMock,
sessionBindingBindMock,
sessionBindingListBySessionMock,
sessionBindingResolveByConversationMock,
sessionBindingUnbindMock,
ensureSessionMock,
runTurnMock,
cancelMock,
closeMock,
getCapabilitiesMock,
getStatusMock,
setModeMock,
setConfigOptionMock,
doctorMock,
};
});
vi.mock("../../gateway/call.js", () => ({
callGateway: (args: unknown) => hoisted.callGatewayMock(args),
}));
vi.mock("../../acp/runtime/registry.js", () => ({
requireAcpRuntimeBackend: (id?: string) => hoisted.requireAcpRuntimeBackendMock(id),
getAcpRuntimeBackend: (id?: string) => hoisted.getAcpRuntimeBackendMock(id),
}));
vi.mock("../../acp/runtime/session-meta.js", () => ({
listAcpSessionEntries: (args: unknown) => hoisted.listAcpSessionEntriesMock(args),
readAcpSessionEntry: (args: unknown) => hoisted.readAcpSessionEntryMock(args),
upsertAcpSessionMeta: (args: unknown) => hoisted.upsertAcpSessionMetaMock(args),
resolveSessionStorePathForAcp: (args: unknown) => hoisted.resolveSessionStorePathForAcpMock(args),
}));
vi.mock("../../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions.js")>();
return {
...actual,
loadSessionStore: (...args: unknown[]) => hoisted.loadSessionStoreMock(...args),
};
});
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
}),
};
});
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
vi.mock("../../discord/monitor/gateway-plugin.js", () => ({
createDiscordGatewayPlugin: () => ({}),
}));
const { handleAcpCommand } = await import("./commands-acp.js");
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
type FakeBinding = {
bindingId: string;
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord";
accountId: string;
conversationId: string;
parentConversationId?: string;
};
status: "active";
boundAt: number;
metadata?: {
agentId?: string;
label?: string;
boundBy?: string;
webhookId?: string;
};
};
function createSessionBinding(overrides?: Partial<FakeBinding>): FakeBinding {
return {
bindingId: "default:thread-created",
targetSessionKey: "agent:codex:acp:s1",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-created",
parentConversationId: "parent-1",
},
status: "active",
boundAt: Date.now(),
metadata: {
agentId: "codex",
boundBy: "user-1",
},
...overrides,
};
}
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
acp: {
enabled: true,
dispatch: { enabled: true },
backend: "acpx",
},
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
function createDiscordParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:parent-1",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
describe("/acp command", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]);
hoisted.callGatewayMock.mockReset().mockResolvedValue({ ok: true });
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue({
sessionId: "session-1",
updatedAt: Date.now(),
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "run-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
hoisted.resolveSessionStorePathForAcpMock.mockReset().mockReturnValue({
cfg: baseCfg,
storePath: "/tmp/sessions-acp.json",
});
hoisted.loadSessionStoreMock.mockReset().mockReturnValue({});
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
hoisted.sessionBindingBindMock
.mockReset()
.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { accountId: string; conversationId: string };
placement: "current" | "child";
metadata?: Record<string, unknown>;
}) =>
createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
accountId: input.conversation.accountId,
conversationId:
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
parentConversationId: "parent-1",
},
metadata: {
boundBy:
typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
webhookId: "wh-1",
},
}),
);
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
hoisted.ensureSessionMock
.mockReset()
.mockImplementation(async (input: { sessionKey: string }) => ({
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `${input.sessionKey}:runtime`,
}));
hoisted.runTurnMock.mockReset().mockImplementation(async function* () {
yield { type: "done" };
});
hoisted.cancelMock.mockReset().mockResolvedValue(undefined);
hoisted.closeMock.mockReset().mockResolvedValue(undefined);
hoisted.getCapabilitiesMock.mockReset().mockResolvedValue({
controls: ["session/set_mode", "session/set_config_option", "session/status"],
});
hoisted.getStatusMock.mockReset().mockResolvedValue({
summary: "status=alive sessionId=sid-1 pid=1234",
details: { status: "alive", sessionId: "sid-1", pid: 1234 },
});
hoisted.setModeMock.mockReset().mockResolvedValue(undefined);
hoisted.setConfigOptionMock.mockReset().mockResolvedValue(undefined);
hoisted.doctorMock.mockReset().mockResolvedValue({
ok: true,
message: "acpx command available",
});
const runtimeBackend = {
id: "acpx",
runtime: {
ensureSession: hoisted.ensureSessionMock,
runTurn: hoisted.runTurnMock,
getCapabilities: hoisted.getCapabilitiesMock,
getStatus: hoisted.getStatusMock,
setMode: hoisted.setModeMock,
setConfigOption: hoisted.setConfigOptionMock,
doctor: hoisted.doctorMock,
cancel: hoisted.cancelMock,
close: hoisted.closeMock,
},
};
hoisted.requireAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend);
hoisted.getAcpRuntimeBackendMock.mockReset().mockReturnValue(runtimeBackend);
});
it("returns null when the message is not /acp", async () => {
const params = createDiscordParams("/status");
const result = await handleAcpCommand(params, true);
expect(result).toBeNull();
});
it("shows help by default", async () => {
const params = createDiscordParams("/acp");
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP commands:");
expect(result?.reply?.text).toContain("/acp spawn");
});
it("spawns an ACP session and binds a Discord thread", async () => {
hoisted.ensureSessionMock.mockResolvedValueOnce({
sessionKey: "agent:codex:acp:s1",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:s1:runtime",
agentSessionId: "codex-inner-1",
backendSessionId: "acpx-1",
});
const params = createDiscordParams("/acp spawn codex --cwd /home/bob/clawd");
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
expect(hoisted.requireAcpRuntimeBackendMock).toHaveBeenCalledWith("acpx");
expect(hoisted.ensureSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
agent: "codex",
mode: "persistent",
cwd: "/home/bob/clawd",
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
targetKind: "session",
placement: "child",
metadata: expect.objectContaining({
introText: expect.stringContaining("cwd: /home/bob/clawd"),
}),
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.not.stringContaining(
"session ids: pending (available after the first reply)",
),
}),
}),
);
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.patch",
}),
);
expect(hoisted.upsertAcpSessionMetaMock).toHaveBeenCalled();
const upsertArgs = hoisted.upsertAcpSessionMetaMock.mock.calls[0]?.[0] as
| {
sessionKey: string;
mutate: (
current: unknown,
entry: { sessionId: string; updatedAt: number } | undefined,
) => {
backend?: string;
runtimeSessionName?: string;
};
}
| undefined;
expect(upsertArgs?.sessionKey).toMatch(/^agent:codex:acp:/);
const seededWithoutEntry = upsertArgs?.mutate(undefined, undefined);
expect(seededWithoutEntry?.backend).toBe("acpx");
expect(seededWithoutEntry?.runtimeSessionName).toContain(":runtime");
});
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
const params = createDiscordParams("/acp spawn");
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP target agent is required");
expect(hoisted.ensureSessionMock).not.toHaveBeenCalled();
});
it("rejects thread-bound ACP spawn when spawnAcpSessions is disabled", async () => {
const cfg = {
...baseCfg,
channels: {
discord: {
threadBindings: {
enabled: true,
spawnAcpSessions: false,
},
},
},
} satisfies OpenClawConfig;
const params = createDiscordParams("/acp spawn codex", cfg);
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.delete",
params: expect.objectContaining({
key: expect.stringMatching(/^agent:codex:acp:/),
deleteTranscript: false,
emitLifecycleHooks: false,
}),
}),
);
expect(hoisted.callGatewayMock).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "sessions.patch" }),
);
});
it("cancels the ACP session bound to the current thread", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "running",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp cancel", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("Cancel requested for ACP session agent:codex:acp:s1");
expect(hoisted.cancelMock).toHaveBeenCalledWith({
handle: expect.objectContaining({
sessionKey: "agent:codex:acp:s1",
backend: "acpx",
}),
reason: "manual-cancel",
});
});
it("sends steer instructions via ACP runtime", async () => {
hoisted.callGatewayMock.mockImplementation(async (request: { method?: string }) => {
if (request.method === "sessions.resolve") {
return { key: "agent:codex:acp:s1" };
}
return { ok: true };
});
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
hoisted.runTurnMock.mockImplementation(async function* () {
yield { type: "text_delta", text: "Applied steering." };
yield { type: "done" };
});
const params = createDiscordParams("/acp steer --session agent:codex:acp:s1 tighten logging");
const result = await handleAcpCommand(params, true);
expect(hoisted.runTurnMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "steer",
text: "tighten logging",
}),
);
expect(result?.reply?.text).toContain("Applied steering.");
});
it("blocks /acp steer when ACP dispatch is disabled by policy", async () => {
const cfg = {
...baseCfg,
acp: {
...baseCfg.acp,
dispatch: { enabled: false },
},
} satisfies OpenClawConfig;
const params = createDiscordParams("/acp steer tighten logging", cfg);
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP dispatch is disabled by policy");
expect(hoisted.runTurnMock).not.toHaveBeenCalled();
});
it("closes an ACP session, unbinds thread targets, and clears metadata", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
hoisted.sessionBindingUnbindMock.mockResolvedValue([
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}) as SessionBindingRecord,
]);
const params = createDiscordParams("/acp close", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(hoisted.closeMock).toHaveBeenCalledTimes(1);
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith(
expect.objectContaining({
targetSessionKey: "agent:codex:acp:s1",
reason: "manual",
}),
);
expect(hoisted.upsertAcpSessionMetaMock).toHaveBeenCalled();
expect(result?.reply?.text).toContain("Removed 1 binding");
});
it("lists ACP sessions from the session store", async () => {
hoisted.sessionBindingListBySessionMock.mockImplementation((key: string) =>
key === "agent:codex:acp:s1"
? [
createSessionBinding({
targetSessionKey: key,
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}) as SessionBindingRecord,
]
: [],
);
hoisted.loadSessionStoreMock.mockReturnValue({
"agent:codex:acp:s1": {
sessionId: "sess-1",
updatedAt: Date.now(),
label: "codex-main",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
},
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
},
});
const params = createDiscordParams("/acp sessions", baseCfg);
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP sessions:");
expect(result?.reply?.text).toContain("codex-main");
expect(result?.reply?.text).toContain("thread:thread-1");
});
it("shows ACP status for the thread-bound ACP session", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
acpxSessionId: "acpx-sid-1",
agentSessionId: "codex-sid-1",
lastUpdatedAt: Date.now(),
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp status", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP status:");
expect(result?.reply?.text).toContain("session: agent:codex:acp:s1");
expect(result?.reply?.text).toContain("agent session id: codex-sid-1");
expect(result?.reply?.text).toContain("acpx session id: acpx-sid-1");
expect(result?.reply?.text).toContain("capabilities:");
expect(hoisted.getStatusMock).toHaveBeenCalledTimes(1);
});
it("updates ACP runtime mode via /acp set-mode", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp set-mode plan", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(hoisted.setModeMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "plan",
}),
);
expect(result?.reply?.text).toContain("Updated ACP runtime mode");
});
it("updates ACP config options and keeps cwd local when using /acp set", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const setModelParams = createDiscordParams("/acp set model gpt-5.3-codex", baseCfg);
setModelParams.ctx.MessageThreadId = "thread-1";
const setModel = await handleAcpCommand(setModelParams, true);
expect(hoisted.setConfigOptionMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "model",
value: "gpt-5.3-codex",
}),
);
expect(setModel?.reply?.text).toContain("Updated ACP config option");
hoisted.setConfigOptionMock.mockClear();
const setCwdParams = createDiscordParams("/acp set cwd /tmp/worktree", baseCfg);
setCwdParams.ctx.MessageThreadId = "thread-1";
const setCwd = await handleAcpCommand(setCwdParams, true);
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
expect(setCwd?.reply?.text).toContain("Updated ACP cwd");
});
it("rejects non-absolute cwd values via ACP runtime option validation", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp cwd relative/path", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
expect(result?.reply?.text).toContain("absolute path");
});
it("rejects invalid timeout values before backend config writes", async () => {
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBinding({
targetSessionKey: "agent:codex:acp:s1",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
}),
);
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex:acp:s1",
storeSessionKey: "agent:codex:acp:s1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const params = createDiscordParams("/acp timeout 10s", baseCfg);
params.ctx.MessageThreadId = "thread-1";
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP error (ACP_INVALID_RUNTIME_OPTION)");
expect(hoisted.setConfigOptionMock).not.toHaveBeenCalled();
});
it("returns actionable doctor output when backend is missing", async () => {
hoisted.getAcpRuntimeBackendMock.mockReturnValue(null);
hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => {
throw new AcpRuntimeError(
"ACP_BACKEND_MISSING",
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
);
});
const params = createDiscordParams("/acp doctor", baseCfg);
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP doctor:");
expect(result?.reply?.text).toContain("healthy: no");
expect(result?.reply?.text).toContain("next:");
});
it("shows deterministic install instructions via /acp install", async () => {
const params = createDiscordParams("/acp install", baseCfg);
const result = await handleAcpCommand(params, true);
expect(result?.reply?.text).toContain("ACP install:");
expect(result?.reply?.text).toContain("run:");
expect(result?.reply?.text).toContain("then: /acp doctor");
});
});

View File

@@ -0,0 +1,83 @@
import { logVerbose } from "../../globals.js";
import {
handleAcpDoctorAction,
handleAcpInstallAction,
handleAcpSessionsAction,
} from "./commands-acp/diagnostics.js";
import {
handleAcpCancelAction,
handleAcpCloseAction,
handleAcpSpawnAction,
handleAcpSteerAction,
} from "./commands-acp/lifecycle.js";
import {
handleAcpCwdAction,
handleAcpModelAction,
handleAcpPermissionsAction,
handleAcpResetOptionsAction,
handleAcpSetAction,
handleAcpSetModeAction,
handleAcpStatusAction,
handleAcpTimeoutAction,
} from "./commands-acp/runtime-options.js";
import {
COMMAND,
type AcpAction,
resolveAcpAction,
resolveAcpHelpText,
stopWithText,
} from "./commands-acp/shared.js";
import type {
CommandHandler,
CommandHandlerResult,
HandleCommandsParams,
} from "./commands-types.js";
type AcpActionHandler = (
params: HandleCommandsParams,
tokens: string[],
) => Promise<CommandHandlerResult>;
const ACP_ACTION_HANDLERS: Record<Exclude<AcpAction, "help">, AcpActionHandler> = {
spawn: handleAcpSpawnAction,
cancel: handleAcpCancelAction,
steer: handleAcpSteerAction,
close: handleAcpCloseAction,
status: handleAcpStatusAction,
"set-mode": handleAcpSetModeAction,
set: handleAcpSetAction,
cwd: handleAcpCwdAction,
permissions: handleAcpPermissionsAction,
timeout: handleAcpTimeoutAction,
model: handleAcpModelAction,
"reset-options": handleAcpResetOptionsAction,
doctor: handleAcpDoctorAction,
install: async (params, tokens) => handleAcpInstallAction(params, tokens),
sessions: async (params, tokens) => handleAcpSessionsAction(params, tokens),
};
export const handleAcpCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (!normalized.startsWith(COMMAND)) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(`Ignoring /acp from unauthorized sender: ${params.command.senderId || "<unknown>"}`);
return { shouldContinue: false };
}
const rest = normalized.slice(COMMAND.length).trim();
const tokens = rest.split(/\s+/).filter(Boolean);
const action = resolveAcpAction(tokens);
if (action === "help") {
return stopWithText(resolveAcpHelpText());
}
const handler = ACP_ACTION_HANDLERS[action];
return handler ? await handler(params, tokens) : stopWithText(resolveAcpHelpText());
};

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
import {
isAcpCommandDiscordChannel,
resolveAcpCommandBindingContext,
resolveAcpCommandConversationId,
} from "./context.js";
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
describe("commands-acp context", () => {
it("resolves channel/account/thread context from originating fields", () => {
const params = buildCommandTestParams("/acp sessions", baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:parent-1",
AccountId: "work",
MessageThreadId: "thread-42",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "discord",
accountId: "work",
threadId: "thread-42",
conversationId: "thread-42",
});
expect(isAcpCommandDiscordChannel(params)).toBe(true);
});
it("falls back to default account and target-derived conversation id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "slack",
Surface: "slack",
OriginatingChannel: "slack",
To: "<#123456789>",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "slack",
accountId: "default",
threadId: undefined,
conversationId: "123456789",
});
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
expect(isAcpCommandDiscordChannel(params)).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import type { HandleCommandsParams } from "../commands-types.js";
function normalizeString(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
return `${value}`.trim();
}
return "";
}
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
const raw =
params.ctx.OriginatingChannel ??
params.command.channel ??
params.ctx.Surface ??
params.ctx.Provider;
return normalizeString(raw).toLowerCase();
}
export function resolveAcpCommandAccountId(params: HandleCommandsParams): string {
const accountId = normalizeString(params.ctx.AccountId);
return accountId || "default";
}
export function resolveAcpCommandThreadId(params: HandleCommandsParams): string | undefined {
const threadId =
params.ctx.MessageThreadId != null ? normalizeString(String(params.ctx.MessageThreadId)) : "";
return threadId || undefined;
}
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
return resolveConversationIdFromTargets({
threadId: params.ctx.MessageThreadId,
targets: [params.ctx.OriginatingTo, params.command.to, params.ctx.To],
});
}
export function isAcpCommandDiscordChannel(params: HandleCommandsParams): boolean {
return resolveAcpCommandChannel(params) === DISCORD_THREAD_BINDING_CHANNEL;
}
export function resolveAcpCommandBindingContext(params: HandleCommandsParams): {
channel: string;
accountId: string;
threadId?: string;
conversationId?: string;
} {
return {
channel: resolveAcpCommandChannel(params),
accountId: resolveAcpCommandAccountId(params),
threadId: resolveAcpCommandThreadId(params),
conversationId: resolveAcpCommandConversationId(params),
};
}

View File

@@ -0,0 +1,203 @@
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import { formatAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import { toAcpRuntimeError } from "../../../acp/runtime/errors.js";
import { getAcpRuntimeBackend, requireAcpRuntimeBackend } from "../../../acp/runtime/registry.js";
import { resolveSessionStorePathForAcp } from "../../../acp/runtime/session-meta.js";
import { loadSessionStore } from "../../../config/sessions.js";
import type { SessionEntry } from "../../../config/sessions/types.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
import {
ACP_DOCTOR_USAGE,
ACP_INSTALL_USAGE,
ACP_SESSIONS_USAGE,
formatAcpCapabilitiesText,
resolveAcpInstallCommandHint,
resolveConfiguredAcpBackendId,
stopWithText,
} from "./shared.js";
import { resolveBoundAcpThreadSessionKey } from "./targets.js";
export async function handleAcpDoctorAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
if (restTokens.length > 0) {
return stopWithText(`⚠️ ${ACP_DOCTOR_USAGE}`);
}
const backendId = resolveConfiguredAcpBackendId(params.cfg);
const installHint = resolveAcpInstallCommandHint(params.cfg);
const registeredBackend = getAcpRuntimeBackend(backendId);
const managerSnapshot = getAcpSessionManager().getObservabilitySnapshot(params.cfg);
const lines = ["ACP doctor:", "-----", `configuredBackend: ${backendId}`];
lines.push(`activeRuntimeSessions: ${managerSnapshot.runtimeCache.activeSessions}`);
lines.push(`runtimeIdleTtlMs: ${managerSnapshot.runtimeCache.idleTtlMs}`);
lines.push(`evictedIdleRuntimes: ${managerSnapshot.runtimeCache.evictedTotal}`);
lines.push(`activeTurns: ${managerSnapshot.turns.active}`);
lines.push(`queueDepth: ${managerSnapshot.turns.queueDepth}`);
lines.push(
`turnLatencyMs: avg=${managerSnapshot.turns.averageLatencyMs}, max=${managerSnapshot.turns.maxLatencyMs}`,
);
lines.push(
`turnCounts: completed=${managerSnapshot.turns.completed}, failed=${managerSnapshot.turns.failed}`,
);
const errorStatsText =
Object.entries(managerSnapshot.errorsByCode)
.map(([code, count]) => `${code}=${count}`)
.join(", ") || "(none)";
lines.push(`errorCodes: ${errorStatsText}`);
if (registeredBackend) {
lines.push(`registeredBackend: ${registeredBackend.id}`);
} else {
lines.push("registeredBackend: (none)");
}
if (registeredBackend?.runtime.doctor) {
try {
const report = await registeredBackend.runtime.doctor();
lines.push(`runtimeDoctor: ${report.ok ? "ok" : "error"} (${report.message})`);
if (report.code) {
lines.push(`runtimeDoctorCode: ${report.code}`);
}
if (report.installCommand) {
lines.push(`runtimeDoctorInstall: ${report.installCommand}`);
}
for (const detail of report.details ?? []) {
lines.push(`runtimeDoctorDetail: ${detail}`);
}
} catch (error) {
lines.push(
`runtimeDoctor: error (${
toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Runtime doctor failed.",
}).message
})`,
);
}
}
try {
const backend = requireAcpRuntimeBackend(backendId);
const capabilities = backend.runtime.getCapabilities
? await backend.runtime.getCapabilities({})
: { controls: [] as string[], configOptionKeys: [] as string[] };
lines.push("healthy: yes");
lines.push(`capabilities: ${formatAcpCapabilitiesText(capabilities.controls ?? [])}`);
if ((capabilities.configOptionKeys?.length ?? 0) > 0) {
lines.push(`configKeys: ${capabilities.configOptionKeys?.join(", ")}`);
}
return stopWithText(lines.join("\n"));
} catch (error) {
const acpError = toAcpRuntimeError({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP backend doctor failed.",
});
lines.push("healthy: no");
lines.push(formatAcpRuntimeErrorText(acpError));
lines.push(`next: ${installHint}`);
lines.push(`next: openclaw config set plugins.entries.${backendId}.enabled true`);
if (backendId.toLowerCase() === "acpx") {
lines.push("next: verify acpx is installed (`acpx --help`).");
}
return stopWithText(lines.join("\n"));
}
}
export function handleAcpInstallAction(
params: HandleCommandsParams,
restTokens: string[],
): CommandHandlerResult {
if (restTokens.length > 0) {
return stopWithText(`⚠️ ${ACP_INSTALL_USAGE}`);
}
const backendId = resolveConfiguredAcpBackendId(params.cfg);
const installHint = resolveAcpInstallCommandHint(params.cfg);
const lines = [
"ACP install:",
"-----",
`configuredBackend: ${backendId}`,
`run: ${installHint}`,
`then: openclaw config set plugins.entries.${backendId}.enabled true`,
"then: /acp doctor",
];
return stopWithText(lines.join("\n"));
}
function formatAcpSessionLine(params: {
key: string;
entry: SessionEntry;
currentSessionKey?: string;
threadId?: string;
}): string {
const acp = params.entry.acp;
if (!acp) {
return "";
}
const marker = params.currentSessionKey === params.key ? "*" : " ";
const label = params.entry.label?.trim() || acp.agent;
const threadText = params.threadId ? `, thread:${params.threadId}` : "";
return `${marker} ${label} (${acp.mode}, ${acp.state}, backend:${acp.backend}${threadText}) -> ${params.key}`;
}
export function handleAcpSessionsAction(
params: HandleCommandsParams,
restTokens: string[],
): CommandHandlerResult {
if (restTokens.length > 0) {
return stopWithText(ACP_SESSIONS_USAGE);
}
const currentSessionKey = resolveBoundAcpThreadSessionKey(params) || params.sessionKey;
if (!currentSessionKey) {
return stopWithText("⚠️ Missing session key.");
}
const { storePath } = resolveSessionStorePathForAcp({
cfg: params.cfg,
sessionKey: currentSessionKey,
});
let store: Record<string, SessionEntry>;
try {
store = loadSessionStore(storePath);
} catch {
store = {};
}
const bindingContext = resolveAcpCommandBindingContext(params);
const normalizedChannel = bindingContext.channel;
const normalizedAccountId = bindingContext.accountId || undefined;
const bindingService = getSessionBindingService();
const rows = Object.entries(store)
.filter(([, entry]) => Boolean(entry?.acp))
.toSorted(([, a], [, b]) => (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0))
.slice(0, 20)
.map(([key, entry]) => {
const bindingThreadId = bindingService
.listBySession(key)
.find(
(binding) =>
(!normalizedChannel || binding.conversation.channel === normalizedChannel) &&
(!normalizedAccountId || binding.conversation.accountId === normalizedAccountId),
)?.conversation.conversationId;
return formatAcpSessionLine({
key,
entry,
currentSessionKey,
threadId: bindingThreadId,
});
})
.filter(Boolean);
if (rows.length === 0) {
return stopWithText("ACP sessions:\n-----\n(none)");
}
return stopWithText(["ACP sessions:", "-----", ...rows].join("\n"));
}

View File

@@ -0,0 +1,588 @@
import { randomUUID } from "node:crypto";
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import {
cleanupFailedAcpSpawn,
type AcpSpawnRuntimeCloseHandle,
} from "../../../acp/control-plane/spawn.js";
import {
isAcpEnabledByPolicy,
resolveAcpAgentPolicyError,
resolveAcpDispatchPolicyError,
resolveAcpDispatchPolicyMessage,
} from "../../../acp/policy.js";
import { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import {
resolveAcpSessionCwd,
resolveAcpThreadSessionDetailLines,
} from "../../../acp/runtime/session-identifiers.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../../../channels/thread-bindings-messages.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSessionTtlMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../../../channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { SessionAcpMeta } from "../../../config/sessions/types.js";
import { callGateway } from "../../../gateway/call.js";
import {
getSessionBindingService,
type SessionBindingRecord,
} from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
resolveAcpCommandAccountId,
resolveAcpCommandBindingContext,
resolveAcpCommandThreadId,
} from "./context.js";
import {
ACP_STEER_OUTPUT_LIMIT,
collectAcpErrorText,
parseSpawnInput,
parseSteerInput,
resolveCommandRequestId,
stopWithText,
type AcpSpawnThreadMode,
withAcpCommandErrorBoundary,
} from "./shared.js";
import { resolveAcpTargetSessionKey } from "./targets.js";
async function bindSpawnedAcpSessionToThread(params: {
commandParams: HandleCommandsParams;
sessionKey: string;
agentId: string;
label?: string;
threadMode: AcpSpawnThreadMode;
sessionMeta?: SessionAcpMeta;
}): Promise<{ ok: true; binding: SessionBindingRecord } | { ok: false; error: string }> {
const { commandParams, threadMode } = params;
if (threadMode === "off") {
return {
ok: false,
error: "internal: thread binding is disabled for this spawn",
};
}
const bindingContext = resolveAcpCommandBindingContext(commandParams);
const channel = bindingContext.channel;
if (!channel) {
return {
ok: false,
error: "ACP thread binding requires a channel context.",
};
}
const accountId = resolveAcpCommandAccountId(commandParams);
const spawnPolicy = resolveThreadBindingSpawnPolicy({
cfg: commandParams.cfg,
channel,
accountId,
kind: "acp",
});
if (!spawnPolicy.enabled) {
return {
ok: false,
error: formatThreadBindingDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "acp",
}),
};
}
if (!spawnPolicy.spawnEnabled) {
return {
ok: false,
error: formatThreadBindingSpawnDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "acp",
}),
};
}
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
});
if (!capabilities.adapterAvailable) {
return {
ok: false,
error: `Thread bindings are unavailable for ${channel}.`,
};
}
if (!capabilities.bindSupported) {
return {
ok: false,
error: `Thread bindings are unavailable for ${channel}.`,
};
}
const currentThreadId = bindingContext.threadId ?? "";
if (threadMode === "here" && !currentThreadId) {
return {
ok: false,
error: `--thread here requires running /acp spawn inside an active ${channel} thread/conversation.`,
};
}
const threadId = currentThreadId || undefined;
const placement = threadId ? "current" : "child";
if (!capabilities.placements.includes(placement)) {
return {
ok: false,
error: `Thread bindings do not support ${placement} placement for ${channel}.`,
};
}
const channelId = placement === "child" ? bindingContext.conversationId : undefined;
if (placement === "child" && !channelId) {
return {
ok: false,
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
};
}
const senderId = commandParams.command.senderId?.trim() || "";
if (threadId) {
const existingBinding = bindingService.resolveByConversation({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: threadId,
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
ok: false,
error: `Only ${boundBy} can rebind this thread.`,
};
}
}
const label = params.label || params.agentId;
const conversationId = threadId || channelId;
if (!conversationId) {
return {
ok: false,
error: `Could not resolve a ${channel} conversation for ACP thread spawn.`,
};
}
try {
const binding = await bindingService.bind({
targetSessionKey: params.sessionKey,
targetKind: "session",
conversation: {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId,
},
placement,
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: params.agentId,
label,
}),
agentId: params.agentId,
label,
boundBy: senderId || "unknown",
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label,
sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({
cfg: commandParams.cfg,
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
}),
sessionCwd: resolveAcpSessionCwd(params.sessionMeta),
sessionDetails: resolveAcpThreadSessionDetailLines({
sessionKey: params.sessionKey,
meta: params.sessionMeta,
}),
}),
},
});
return {
ok: true,
binding,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
error: message || `Failed to bind a ${channel} thread/conversation to the new ACP session.`,
};
}
}
async function cleanupFailedSpawn(params: {
cfg: OpenClawConfig;
sessionKey: string;
shouldDeleteSession: boolean;
initializedRuntime?: AcpSpawnRuntimeCloseHandle;
}) {
await cleanupFailedAcpSpawn({
cfg: params.cfg,
sessionKey: params.sessionKey,
shouldDeleteSession: params.shouldDeleteSession,
deleteTranscript: false,
runtimeCloseHandle: params.initializedRuntime,
});
}
export async function handleAcpSpawnAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
if (!isAcpEnabledByPolicy(params.cfg)) {
return stopWithText("ACP is disabled by policy (`acp.enabled=false`).");
}
const parsed = parseSpawnInput(params, restTokens);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const spawn = parsed.value;
const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, spawn.agentId);
if (agentPolicyError) {
return stopWithText(
collectAcpErrorText({
error: agentPolicyError,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "ACP target agent is not allowed by policy.",
}),
);
}
const acpManager = getAcpSessionManager();
const sessionKey = `agent:${spawn.agentId}:acp:${randomUUID()}`;
let initializedBackend = "";
let initializedMeta: SessionAcpMeta | undefined;
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
try {
const initialized = await acpManager.initializeSession({
cfg: params.cfg,
sessionKey,
agent: spawn.agentId,
mode: spawn.mode,
cwd: spawn.cwd,
});
initializedRuntime = {
runtime: initialized.runtime,
handle: initialized.handle,
};
initializedBackend = initialized.handle.backend || initialized.meta.backend;
initializedMeta = initialized.meta;
} catch (err) {
return stopWithText(
collectAcpErrorText({
error: err,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Could not initialize ACP session runtime.",
}),
);
}
let binding: SessionBindingRecord | null = null;
if (spawn.thread !== "off") {
const bound = await bindSpawnedAcpSessionToThread({
commandParams: params,
sessionKey,
agentId: spawn.agentId,
label: spawn.label,
threadMode: spawn.thread,
sessionMeta: initializedMeta,
});
if (!bound.ok) {
await cleanupFailedSpawn({
cfg: params.cfg,
sessionKey,
shouldDeleteSession: true,
initializedRuntime,
});
return stopWithText(`⚠️ ${bound.error}`);
}
binding = bound.binding;
}
try {
await callGateway({
method: "sessions.patch",
params: {
key: sessionKey,
...(spawn.label ? { label: spawn.label } : {}),
},
timeoutMs: 10_000,
});
} catch (err) {
await cleanupFailedSpawn({
cfg: params.cfg,
sessionKey,
shouldDeleteSession: true,
initializedRuntime,
});
const message = err instanceof Error ? err.message : String(err);
return stopWithText(`⚠️ ACP spawn failed: ${message}`);
}
const parts = [
`✅ Spawned ACP session ${sessionKey} (${spawn.mode}, backend ${initializedBackend}).`,
];
if (binding) {
const currentThreadId = resolveAcpCommandThreadId(params) ?? "";
const boundConversationId = binding.conversation.conversationId.trim();
if (currentThreadId && boundConversationId === currentThreadId) {
parts.push(`Bound this thread to ${sessionKey}.`);
} else {
parts.push(`Created thread ${boundConversationId} and bound it to ${sessionKey}.`);
}
} else {
parts.push("Session is unbound (use /focus <session-key> to bind this thread/conversation).");
}
const dispatchNote = resolveAcpDispatchPolicyMessage(params.cfg);
if (dispatchNote) {
parts.push(` ${dispatchNote}`);
}
return stopWithText(parts.join(" "));
}
export async function handleAcpCancelAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const acpManager = getAcpSessionManager();
const token = restTokens.join(" ").trim() || undefined;
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const resolved = acpManager.resolveSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
});
if (resolved.kind === "none") {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${target.sessionKey}`,
),
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
}),
);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await acpManager.cancelSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
reason: "manual-cancel",
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP cancel failed before completion.",
onSuccess: () => stopWithText(`✅ Cancel requested for ACP session ${target.sessionKey}.`),
});
}
async function runAcpSteer(params: {
cfg: OpenClawConfig;
sessionKey: string;
instruction: string;
requestId: string;
}): Promise<string> {
const acpManager = getAcpSessionManager();
let output = "";
await acpManager.runTurn({
cfg: params.cfg,
sessionKey: params.sessionKey,
text: params.instruction,
mode: "steer",
requestId: params.requestId,
onEvent: (event) => {
if (event.type !== "text_delta") {
return;
}
if (event.stream && event.stream !== "output") {
return;
}
if (event.text) {
output += event.text;
if (output.length > ACP_STEER_OUTPUT_LIMIT) {
output = `${output.slice(0, ACP_STEER_OUTPUT_LIMIT)}`;
}
}
},
});
return output.trim();
}
export async function handleAcpSteerAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const dispatchPolicyError = resolveAcpDispatchPolicyError(params.cfg);
if (dispatchPolicyError) {
return stopWithText(
collectAcpErrorText({
error: dispatchPolicyError,
fallbackCode: "ACP_DISPATCH_DISABLED",
fallbackMessage: dispatchPolicyError.message,
}),
);
}
const parsed = parseSteerInput(restTokens);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const acpManager = getAcpSessionManager();
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const resolved = acpManager.resolveSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
});
if (resolved.kind === "none") {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${target.sessionKey}`,
),
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
}),
);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await runAcpSteer({
cfg: params.cfg,
sessionKey: target.sessionKey,
instruction: parsed.value.instruction,
requestId: `${resolveCommandRequestId(params)}:steer`,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP steer failed before completion.",
onSuccess: (steerOutput) => {
if (!steerOutput) {
return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.`);
}
return stopWithText(`✅ ACP steer sent to ${target.sessionKey}.\n${steerOutput}`);
},
});
}
export async function handleAcpCloseAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const acpManager = getAcpSessionManager();
const token = restTokens.join(" ").trim() || undefined;
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const resolved = acpManager.resolveSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
});
if (resolved.kind === "none") {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${target.sessionKey}`,
),
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
}),
);
}
let runtimeNotice = "";
try {
const closed = await acpManager.closeSession({
cfg: params.cfg,
sessionKey: target.sessionKey,
reason: "manual-close",
allowBackendUnavailable: true,
clearMeta: true,
});
runtimeNotice = closed.runtimeNotice ? ` (${closed.runtimeNotice})` : "";
} catch (error) {
return stopWithText(
collectAcpErrorText({
error,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP close failed before completion.",
}),
);
}
const removedBindings = await getSessionBindingService().unbind({
targetSessionKey: target.sessionKey,
reason: "manual",
});
return stopWithText(
`✅ Closed ACP session ${target.sessionKey}${runtimeNotice}. Removed ${removedBindings.length} binding${removedBindings.length === 1 ? "" : "s"}.`,
);
}

View File

@@ -0,0 +1,348 @@
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import {
parseRuntimeTimeoutSecondsInput,
validateRuntimeConfigOptionInput,
validateRuntimeCwdInput,
validateRuntimeModeInput,
validateRuntimeModelInput,
validateRuntimePermissionProfileInput,
} from "../../../acp/control-plane/runtime-options.js";
import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
ACP_CWD_USAGE,
ACP_MODEL_USAGE,
ACP_PERMISSIONS_USAGE,
ACP_RESET_OPTIONS_USAGE,
ACP_SET_MODE_USAGE,
ACP_STATUS_USAGE,
ACP_TIMEOUT_USAGE,
formatAcpCapabilitiesText,
formatRuntimeOptionsText,
parseOptionalSingleTarget,
parseSetCommandInput,
parseSingleValueCommandInput,
stopWithText,
withAcpCommandErrorBoundary,
} from "./shared.js";
import { resolveAcpTargetSessionKey } from "./targets.js";
export async function handleAcpStatusAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseOptionalSingleTarget(restTokens, ACP_STATUS_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await getAcpSessionManager().getSessionStatus({
cfg: params.cfg,
sessionKey: target.sessionKey,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP session status.",
onSuccess: (status) => {
const sessionIdentifierLines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: status.backend,
identity: status.identity,
});
const lines = [
"ACP status:",
"-----",
`session: ${status.sessionKey}`,
`backend: ${status.backend}`,
`agent: ${status.agent}`,
...sessionIdentifierLines,
`sessionMode: ${status.mode}`,
`state: ${status.state}`,
`runtimeOptions: ${formatRuntimeOptionsText(status.runtimeOptions)}`,
`capabilities: ${formatAcpCapabilitiesText(status.capabilities.controls)}`,
`lastActivityAt: ${new Date(status.lastActivityAt).toISOString()}`,
...(status.lastError ? [`lastError: ${status.lastError}`] : []),
...(status.runtimeStatus?.summary ? [`runtime: ${status.runtimeStatus.summary}`] : []),
...(status.runtimeStatus?.details
? [`runtimeDetails: ${JSON.stringify(status.runtimeStatus.details)}`]
: []),
];
return stopWithText(lines.join("\n"));
},
});
}
export async function handleAcpSetModeAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_SET_MODE_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const runtimeMode = validateRuntimeModeInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionRuntimeMode({
cfg: params.cfg,
sessionKey: target.sessionKey,
runtimeMode,
});
return {
runtimeMode,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP runtime mode.",
onSuccess: ({ runtimeMode, options }) =>
stopWithText(
`✅ Updated ACP runtime mode for ${target.sessionKey}: ${runtimeMode}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpSetAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSetCommandInput(restTokens);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
const key = parsed.value.key.trim();
const value = parsed.value.value.trim();
return await withAcpCommandErrorBoundary({
run: async () => {
const lowerKey = key.toLowerCase();
if (lowerKey === "cwd") {
const cwd = validateRuntimeCwdInput(value);
const options = await getAcpSessionManager().updateSessionRuntimeOptions({
cfg: params.cfg,
sessionKey: target.sessionKey,
patch: { cwd },
});
return {
text: `✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`,
};
}
const validated = validateRuntimeConfigOptionInput(key, value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: validated.key,
value: validated.value,
});
return {
text: `✅ Updated ACP config option for ${target.sessionKey}: ${validated.key}=${validated.value}. Effective options: ${formatRuntimeOptionsText(options)}`,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP config option.",
onSuccess: ({ text }) => stopWithText(text),
});
}
export async function handleAcpCwdAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_CWD_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const cwd = validateRuntimeCwdInput(parsed.value.value);
const options = await getAcpSessionManager().updateSessionRuntimeOptions({
cfg: params.cfg,
sessionKey: target.sessionKey,
patch: { cwd },
});
return {
cwd,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP cwd.",
onSuccess: ({ cwd, options }) =>
stopWithText(
`✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpPermissionsAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_PERMISSIONS_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const permissionProfile = validateRuntimePermissionProfileInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: "approval_policy",
value: permissionProfile,
});
return {
permissionProfile,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP permissions profile.",
onSuccess: ({ permissionProfile, options }) =>
stopWithText(
`✅ Updated ACP permissions profile for ${target.sessionKey}: ${permissionProfile}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpTimeoutAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_TIMEOUT_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const timeoutSeconds = parseRuntimeTimeoutSecondsInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: "timeout",
value: String(timeoutSeconds),
});
return {
timeoutSeconds,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP timeout.",
onSuccess: ({ timeoutSeconds, options }) =>
stopWithText(
`✅ Updated ACP timeout for ${target.sessionKey}: ${timeoutSeconds}s. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpModelAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseSingleValueCommandInput(restTokens, ACP_MODEL_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.value.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () => {
const model = validateRuntimeModelInput(parsed.value.value);
const options = await getAcpSessionManager().setSessionConfigOption({
cfg: params.cfg,
sessionKey: target.sessionKey,
key: "model",
value: model,
});
return {
model,
options,
};
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not update ACP model.",
onSuccess: ({ model, options }) =>
stopWithText(
`✅ Updated ACP model for ${target.sessionKey}: ${model}. Effective options: ${formatRuntimeOptionsText(options)}`,
),
});
}
export async function handleAcpResetOptionsAction(
params: HandleCommandsParams,
restTokens: string[],
): Promise<CommandHandlerResult> {
const parsed = parseOptionalSingleTarget(restTokens, ACP_RESET_OPTIONS_USAGE);
if (!parsed.ok) {
return stopWithText(`⚠️ ${parsed.error}`);
}
const target = await resolveAcpTargetSessionKey({
commandParams: params,
token: parsed.sessionToken,
});
if (!target.ok) {
return stopWithText(`⚠️ ${target.error}`);
}
return await withAcpCommandErrorBoundary({
run: async () =>
await getAcpSessionManager().resetSessionRuntimeOptions({
cfg: params.cfg,
sessionKey: target.sessionKey,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not reset ACP runtime options.",
onSuccess: () => stopWithText(`✅ Reset ACP runtime options for ${target.sessionKey}.`),
});
}

View File

@@ -0,0 +1,500 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import path from "node:path";
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandChannel, resolveAcpCommandThreadId } from "./context.js";
export const COMMAND = "/acp";
export const ACP_SPAWN_USAGE =
"Usage: /acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>].";
export const ACP_STEER_USAGE =
"Usage: /acp steer [--session <session-key|session-id|session-label>] <instruction>";
export const ACP_SET_MODE_USAGE =
"Usage: /acp set-mode <mode> [session-key|session-id|session-label]";
export const ACP_SET_USAGE = "Usage: /acp set <key> <value> [session-key|session-id|session-label]";
export const ACP_CWD_USAGE = "Usage: /acp cwd <path> [session-key|session-id|session-label]";
export const ACP_PERMISSIONS_USAGE =
"Usage: /acp permissions <profile> [session-key|session-id|session-label]";
export const ACP_TIMEOUT_USAGE =
"Usage: /acp timeout <seconds> [session-key|session-id|session-label]";
export const ACP_MODEL_USAGE =
"Usage: /acp model <model-id> [session-key|session-id|session-label]";
export const ACP_RESET_OPTIONS_USAGE =
"Usage: /acp reset-options [session-key|session-id|session-label]";
export const ACP_STATUS_USAGE = "Usage: /acp status [session-key|session-id|session-label]";
export const ACP_INSTALL_USAGE = "Usage: /acp install";
export const ACP_DOCTOR_USAGE = "Usage: /acp doctor";
export const ACP_SESSIONS_USAGE = "Usage: /acp sessions";
export const ACP_STEER_OUTPUT_LIMIT = 800;
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export type AcpAction =
| "spawn"
| "cancel"
| "steer"
| "close"
| "sessions"
| "status"
| "set-mode"
| "set"
| "cwd"
| "permissions"
| "timeout"
| "model"
| "reset-options"
| "doctor"
| "install"
| "help";
export type AcpSpawnThreadMode = "auto" | "here" | "off";
export type ParsedSpawnInput = {
agentId: string;
mode: AcpRuntimeSessionMode;
thread: AcpSpawnThreadMode;
cwd?: string;
label?: string;
};
export type ParsedSteerInput = {
sessionToken?: string;
instruction: string;
};
export type ParsedSingleValueCommandInput = {
value: string;
sessionToken?: string;
};
export type ParsedSetCommandInput = {
key: string;
value: string;
sessionToken?: string;
};
export function stopWithText(text: string): CommandHandlerResult {
return {
shouldContinue: false,
reply: { text },
};
}
export function resolveAcpAction(tokens: string[]): AcpAction {
const action = tokens[0]?.trim().toLowerCase();
if (
action === "spawn" ||
action === "cancel" ||
action === "steer" ||
action === "close" ||
action === "sessions" ||
action === "status" ||
action === "set-mode" ||
action === "set" ||
action === "cwd" ||
action === "permissions" ||
action === "timeout" ||
action === "model" ||
action === "reset-options" ||
action === "doctor" ||
action === "install" ||
action === "help"
) {
tokens.shift();
return action;
}
return "help";
}
function readOptionValue(params: { tokens: string[]; index: number; flag: string }):
| {
matched: true;
value?: string;
nextIndex: number;
error?: string;
}
| { matched: false } {
const token = params.tokens[params.index] ?? "";
if (token === params.flag) {
const nextValue = params.tokens[params.index + 1]?.trim() ?? "";
if (!nextValue || nextValue.startsWith("--")) {
return {
matched: true,
nextIndex: params.index + 1,
error: `${params.flag} requires a value`,
};
}
return {
matched: true,
value: nextValue,
nextIndex: params.index + 2,
};
}
if (token.startsWith(`${params.flag}=`)) {
const value = token.slice(`${params.flag}=`.length).trim();
if (!value) {
return {
matched: true,
nextIndex: params.index + 1,
error: `${params.flag} requires a value`,
};
}
return {
matched: true,
value,
nextIndex: params.index + 1,
};
}
return { matched: false };
}
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
return "off";
}
const currentThreadId = resolveAcpCommandThreadId(params);
return currentThreadId ? "here" : "auto";
}
export function parseSpawnInput(
params: HandleCommandsParams,
tokens: string[],
): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } {
let mode: AcpRuntimeSessionMode = "persistent";
let thread = resolveDefaultSpawnThreadMode(params);
let cwd: string | undefined;
let label: string | undefined;
let rawAgentId: string | undefined;
for (let i = 0; i < tokens.length; ) {
const token = tokens[i] ?? "";
const modeOption = readOptionValue({ tokens, index: i, flag: "--mode" });
if (modeOption.matched) {
if (modeOption.error) {
return { ok: false, error: `${modeOption.error}. ${ACP_SPAWN_USAGE}` };
}
const raw = modeOption.value?.trim().toLowerCase();
if (raw !== "persistent" && raw !== "oneshot") {
return {
ok: false,
error: `Invalid --mode value "${modeOption.value}". Use persistent or oneshot.`,
};
}
mode = raw;
i = modeOption.nextIndex;
continue;
}
const threadOption = readOptionValue({ tokens, index: i, flag: "--thread" });
if (threadOption.matched) {
if (threadOption.error) {
return { ok: false, error: `${threadOption.error}. ${ACP_SPAWN_USAGE}` };
}
const raw = threadOption.value?.trim().toLowerCase();
if (raw !== "auto" && raw !== "here" && raw !== "off") {
return {
ok: false,
error: `Invalid --thread value "${threadOption.value}". Use auto, here, or off.`,
};
}
thread = raw;
i = threadOption.nextIndex;
continue;
}
const cwdOption = readOptionValue({ tokens, index: i, flag: "--cwd" });
if (cwdOption.matched) {
if (cwdOption.error) {
return { ok: false, error: `${cwdOption.error}. ${ACP_SPAWN_USAGE}` };
}
cwd = cwdOption.value?.trim();
i = cwdOption.nextIndex;
continue;
}
const labelOption = readOptionValue({ tokens, index: i, flag: "--label" });
if (labelOption.matched) {
if (labelOption.error) {
return { ok: false, error: `${labelOption.error}. ${ACP_SPAWN_USAGE}` };
}
label = labelOption.value?.trim();
i = labelOption.nextIndex;
continue;
}
if (token.startsWith("--")) {
return {
ok: false,
error: `Unknown option: ${token}. ${ACP_SPAWN_USAGE}`,
};
}
if (!rawAgentId) {
rawAgentId = token.trim();
i += 1;
continue;
}
return {
ok: false,
error: `Unexpected argument: ${token}. ${ACP_SPAWN_USAGE}`,
};
}
const fallbackAgent = params.cfg.acp?.defaultAgent?.trim() || "";
const selectedAgent = (rawAgentId?.trim() || fallbackAgent).trim();
if (!selectedAgent) {
return {
ok: false,
error: `ACP target agent is required. Pass an agent id or configure acp.defaultAgent. ${ACP_SPAWN_USAGE}`,
};
}
const normalizedAgentId = normalizeAgentId(selectedAgent);
return {
ok: true,
value: {
agentId: normalizedAgentId,
mode,
thread,
cwd,
label: label || undefined,
},
};
}
export function parseSteerInput(
tokens: string[],
): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } {
let sessionToken: string | undefined;
const instructionTokens: string[] = [];
for (let i = 0; i < tokens.length; ) {
const sessionOption = readOptionValue({
tokens,
index: i,
flag: "--session",
});
if (sessionOption.matched) {
if (sessionOption.error) {
return {
ok: false,
error: `${sessionOption.error}. ${ACP_STEER_USAGE}`,
};
}
sessionToken = sessionOption.value?.trim() || undefined;
i = sessionOption.nextIndex;
continue;
}
instructionTokens.push(tokens[i]);
i += 1;
}
const instruction = instructionTokens.join(" ").trim();
if (!instruction) {
return {
ok: false,
error: ACP_STEER_USAGE,
};
}
return {
ok: true,
value: {
sessionToken,
instruction,
},
};
}
export function parseSingleValueCommandInput(
tokens: string[],
usage: string,
): { ok: true; value: ParsedSingleValueCommandInput } | { ok: false; error: string } {
const value = tokens[0]?.trim() || "";
if (!value) {
return { ok: false, error: usage };
}
if (tokens.length > 2) {
return { ok: false, error: usage };
}
const sessionToken = tokens[1]?.trim() || undefined;
return {
ok: true,
value: {
value,
sessionToken,
},
};
}
export function parseSetCommandInput(
tokens: string[],
): { ok: true; value: ParsedSetCommandInput } | { ok: false; error: string } {
const key = tokens[0]?.trim() || "";
const value = tokens[1]?.trim() || "";
if (!key || !value) {
return {
ok: false,
error: ACP_SET_USAGE,
};
}
if (tokens.length > 3) {
return {
ok: false,
error: ACP_SET_USAGE,
};
}
const sessionToken = tokens[2]?.trim() || undefined;
return {
ok: true,
value: {
key,
value,
sessionToken,
},
};
}
export function parseOptionalSingleTarget(
tokens: string[],
usage: string,
): { ok: true; sessionToken?: string } | { ok: false; error: string } {
if (tokens.length > 1) {
return { ok: false, error: usage };
}
const token = tokens[0]?.trim() || "";
return {
ok: true,
...(token ? { sessionToken: token } : {}),
};
}
export function resolveAcpHelpText(): string {
return [
"ACP commands:",
"-----",
"/acp spawn [agentId] [--mode persistent|oneshot] [--thread auto|here|off] [--cwd <path>] [--label <label>]",
"/acp cancel [session-key|session-id|session-label]",
"/acp steer [--session <session-key|session-id|session-label>] <instruction>",
"/acp close [session-key|session-id|session-label]",
"/acp status [session-key|session-id|session-label]",
"/acp set-mode <mode> [session-key|session-id|session-label]",
"/acp set <key> <value> [session-key|session-id|session-label]",
"/acp cwd <path> [session-key|session-id|session-label]",
"/acp permissions <profile> [session-key|session-id|session-label]",
"/acp timeout <seconds> [session-key|session-id|session-label]",
"/acp model <model-id> [session-key|session-id|session-label]",
"/acp reset-options [session-key|session-id|session-label]",
"/acp doctor",
"/acp install",
"/acp sessions",
"",
"Notes:",
"- /focus and /unfocus also work with ACP session keys.",
"- ACP dispatch of normal thread messages is controlled by acp.dispatch.enabled.",
].join("\n");
}
export function resolveConfiguredAcpBackendId(cfg: OpenClawConfig): string {
return cfg.acp?.backend?.trim() || "acpx";
}
export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string {
const configured = cfg.acp?.runtime?.installCommand?.trim();
if (configured) {
return configured;
}
const backendId = resolveConfiguredAcpBackendId(cfg).toLowerCase();
if (backendId === "acpx") {
const localPath = path.resolve(process.cwd(), "extensions/acpx");
if (existsSync(localPath)) {
return `openclaw plugins install ${localPath}`;
}
return "openclaw plugins install @openclaw/acpx";
}
return `Install and enable the plugin that provides ACP backend "${backendId}".`;
}
export function formatRuntimeOptionsText(options: AcpSessionRuntimeOptions): string {
const extras = options.backendExtras
? Object.entries(options.backendExtras)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join(", ")
: "";
const parts = [
options.runtimeMode ? `runtimeMode=${options.runtimeMode}` : null,
options.model ? `model=${options.model}` : null,
options.cwd ? `cwd=${options.cwd}` : null,
options.permissionProfile ? `permissionProfile=${options.permissionProfile}` : null,
typeof options.timeoutSeconds === "number" ? `timeoutSeconds=${options.timeoutSeconds}` : null,
extras ? `extras={${extras}}` : null,
].filter(Boolean) as string[];
if (parts.length === 0) {
return "(none)";
}
return parts.join(", ");
}
export function formatAcpCapabilitiesText(controls: string[]): string {
if (controls.length === 0) {
return "(none)";
}
return controls.toSorted().join(", ");
}
export function resolveCommandRequestId(params: HandleCommandsParams): string {
const value =
params.ctx.MessageSidFull ??
params.ctx.MessageSid ??
params.ctx.MessageSidFirst ??
params.ctx.MessageSidLast;
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value);
}
return randomUUID();
}
export function collectAcpErrorText(params: {
error: unknown;
fallbackCode: AcpRuntimeError["code"];
fallbackMessage: string;
}): string {
return toAcpRuntimeErrorText({
error: params.error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
});
}
export async function withAcpCommandErrorBoundary<T>(params: {
run: () => Promise<T>;
fallbackCode: AcpRuntimeError["code"];
fallbackMessage: string;
onSuccess: (value: T) => CommandHandlerResult;
}): Promise<CommandHandlerResult> {
try {
const result = await params.run();
return params.onSuccess(result);
} catch (error) {
return stopWithText(
collectAcpErrorText({
error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
}),
);
}
}

View File

@@ -0,0 +1,90 @@
import { callGateway } from "../../../gateway/call.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import { resolveRequesterSessionKey } from "../commands-subagents/shared.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
import { SESSION_ID_RE } from "./shared.js";
async function resolveSessionKeyByToken(token: string): Promise<string | null> {
const trimmed = token.trim();
if (!trimmed) {
return null;
}
const attempts: Array<Record<string, string>> = [{ key: trimmed }];
if (SESSION_ID_RE.test(trimmed)) {
attempts.push({ sessionId: trimmed });
}
attempts.push({ label: trimmed });
for (const params of attempts) {
try {
const resolved = await callGateway<{ key?: string }>({
method: "sessions.resolve",
params,
timeoutMs: 8_000,
});
const key = typeof resolved?.key === "string" ? resolved.key.trim() : "";
if (key) {
return key;
}
} catch {
// Try next resolver strategy.
}
}
return null;
}
export function resolveBoundAcpThreadSessionKey(params: HandleCommandsParams): string | undefined {
const bindingContext = resolveAcpCommandBindingContext(params);
if (!bindingContext.channel || !bindingContext.conversationId) {
return undefined;
}
const binding = getSessionBindingService().resolveByConversation({
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
});
if (!binding || binding.targetKind !== "session") {
return undefined;
}
return binding.targetSessionKey.trim() || undefined;
}
export async function resolveAcpTargetSessionKey(params: {
commandParams: HandleCommandsParams;
token?: string;
}): Promise<{ ok: true; sessionKey: string } | { ok: false; error: string }> {
const token = params.token?.trim() || "";
if (token) {
const resolved = await resolveSessionKeyByToken(token);
if (!resolved) {
return {
ok: false,
error: `Unable to resolve session target: ${token}`,
};
}
return { ok: true, sessionKey: resolved };
}
const threadBound = resolveBoundAcpThreadSessionKey(params.commandParams);
if (threadBound) {
return {
ok: true,
sessionKey: threadBound,
};
}
const fallback = resolveRequesterSessionKey(params.commandParams, {
preferCommandTarget: true,
});
if (!fallback) {
return {
ok: false,
error: "Missing session key.",
};
}
return {
ok: true,
sessionKey: fallback,
};
}

View File

@@ -4,6 +4,7 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { handleAcpCommand } from "./commands-acp.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleBashCommand } from "./commands-bash.js";
@@ -150,6 +151,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleExportSessionCommand,
handleWhoamiCommand,
handleSubagentsCommand,
handleAcpCommand,
handleConfigCommand,
handleDebugCommand,
handleModelsCommand,

View File

@@ -4,16 +4,29 @@ import {
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js";
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
const getThreadBindingManagerMock = vi.fn();
const resolveThreadBindingThreadNameMock = vi.fn(() => "🤖 codex");
const readAcpSessionEntryMock = vi.fn();
const sessionBindingCapabilitiesMock = vi.fn();
const sessionBindingBindMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
const sessionBindingListBySessionMock = vi.fn();
const sessionBindingUnbindMock = vi.fn();
return {
callGatewayMock,
getThreadBindingManagerMock,
resolveThreadBindingThreadNameMock,
readAcpSessionEntryMock,
sessionBindingCapabilitiesMock,
sessionBindingBindMock,
sessionBindingResolveByConversationMock,
sessionBindingListBySessionMock,
sessionBindingUnbindMock,
};
});
@@ -21,6 +34,14 @@ vi.mock("../../gateway/call.js", () => ({
callGateway: hoisted.callGatewayMock,
}));
vi.mock("../../acp/runtime/session-meta.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../acp/runtime/session-meta.js")>();
return {
...actual,
readAcpSessionEntry: (params: unknown) => hoisted.readAcpSessionEntryMock(params),
};
});
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
return {
@@ -30,6 +51,23 @@ vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
};
});
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: (input: unknown) => hoisted.sessionBindingBindMock(input),
getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params),
listBySession: (targetSessionKey: string) =>
hoisted.sessionBindingListBySessionMock(targetSessionKey),
resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref),
touch: vi.fn(),
unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input),
}),
};
});
installSubagentsCommandCoreMocks();
const { handleSubagentsCommand } = await import("./commands-subagents.js");
@@ -155,8 +193,56 @@ function createStoredBinding(overrides?: Partial<FakeBinding>): FakeBinding {
};
}
async function focusCodexAcpInThread(fake = createFakeThreadBindingManager()) {
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
function createSessionBindingRecord(
overrides?: Partial<SessionBindingRecord>,
): SessionBindingRecord {
return {
bindingId: "default:thread-1",
targetSessionKey: "agent:codex-acp:session-1",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
parentConversationId: "parent-1",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
agentId: "codex-acp",
},
...overrides,
};
}
async function focusCodexAcpInThread(options?: { existingBinding?: SessionBindingRecord | null }) {
hoisted.sessionBindingCapabilitiesMock.mockReturnValue({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(options?.existingBinding ?? null);
hoisted.sessionBindingBindMock.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { accountId: string; conversationId: string };
metadata?: Record<string, unknown>;
}) =>
createSessionBindingRecord({
targetSessionKey: input.targetSessionKey,
conversation: {
channel: "discord",
accountId: input.conversation.accountId,
conversationId: input.conversation.conversationId,
parentConversationId: "parent-1",
},
metadata: {
boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
},
}),
);
hoisted.callGatewayMock.mockImplementation(async (request: unknown) => {
const method = (request as { method?: string }).method;
if (method === "sessions.resolve") {
@@ -166,7 +252,7 @@ async function focusCodexAcpInThread(fake = createFakeThreadBindingManager()) {
});
const params = createDiscordCommandParams("/focus codex-acp");
const result = await handleSubagentsCommand(params, true);
return { fake, result };
return { result };
}
describe("/focus, /unfocus, /agents", () => {
@@ -175,21 +261,79 @@ describe("/focus, /unfocus, /agents", () => {
hoisted.callGatewayMock.mockClear();
hoisted.getThreadBindingManagerMock.mockClear().mockReturnValue(null);
hoisted.resolveThreadBindingThreadNameMock.mockClear().mockReturnValue("🤖 codex");
hoisted.readAcpSessionEntryMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]);
hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]);
hoisted.sessionBindingBindMock.mockReset();
});
it("/focus resolves ACP sessions and binds the current Discord thread", async () => {
const { fake, result } = await focusCodexAcpInThread();
const { result } = await focusCodexAcpInThread();
expect(result?.reply?.text).toContain("bound this thread");
expect(result?.reply?.text).toContain("(acp)");
expect(fake.manager.bindTarget).toHaveBeenCalledWith(
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
createThread: false,
targetKind: "acp",
placement: "current",
targetKind: "session",
targetSessionKey: "agent:codex-acp:session-1",
introText:
"🤖 codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
metadata: expect.objectContaining({
introText:
"⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
}),
}),
);
});
it("/focus includes ACP session identifiers in intro text when available", async () => {
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
acpxSessionId: "acpx-456",
agentSessionId: "codex-123",
lastUpdatedAt: Date.now(),
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const { result } = await focusCodexAcpInThread();
expect(result?.reply?.text).toContain("bound this thread");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.stringContaining("agent session id: codex-123"),
}),
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.stringContaining("acpx session id: acpx-456"),
}),
}),
);
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
introText: expect.stringContaining("codex resume codex-123"),
}),
}),
);
});
@@ -210,12 +354,40 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/unfocus also unbinds ACP-focused thread bindings", async () => {
const fake = createFakeThreadBindingManager([
createStoredBinding({
targetKind: "acp",
targetSessionKey: "agent:codex:acp:session-1",
agentId: "codex",
label: "codex-session",
}),
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/unfocus");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
reason: "manual",
}),
);
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
const fake = createFakeThreadBindingManager([createStoredBinding({ boundBy: "user-2" })]);
const { result } = await focusCodexAcpInThread(fake);
const { result } = await focusCodexAcpInThread({
existingBinding: createSessionBindingRecord({
metadata: {
boundBy: "user-2",
},
}),
});
expect(result?.reply?.text).toContain("Only user-2 can refocus this thread.");
expect(fake.manager.bindTarget).not.toHaveBeenCalled();
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("/agents includes bound persistent sessions and requester-scoped ACP bindings", async () => {

View File

@@ -1,8 +1,14 @@
import {
getThreadBindingManager,
resolveAcpSessionCwd,
resolveAcpThreadSessionDetailLines,
} from "../../../acp/runtime/session-identifiers.js";
import { readAcpSessionEntry } from "../../../acp/runtime/session-meta.js";
import {
resolveDiscordThreadBindingSessionTtlMs,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../../../discord/monitor/thread-bindings.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
@@ -27,8 +33,12 @@ export async function handleSubagentsFocusAction(
}
const accountId = resolveDiscordAccountId(params);
const threadBindings = getThreadBindingManager(accountId);
if (!threadBindings) {
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: "discord",
accountId,
});
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
}
@@ -46,45 +56,80 @@ export async function handleSubagentsFocusAction(
const senderId = params.command.senderId?.trim() || "";
if (currentThreadId) {
const existingBinding = threadBindings.getByThreadId(currentThreadId);
if (
existingBinding &&
existingBinding.boundBy &&
existingBinding.boundBy !== "system" &&
senderId &&
senderId !== existingBinding.boundBy
) {
return stopWithText(`⚠️ Only ${existingBinding.boundBy} can refocus this thread.`);
const existingBinding = bindingService.resolveByConversation({
channel: "discord",
accountId,
conversationId: currentThreadId,
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
: "";
if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return stopWithText(`⚠️ Only ${boundBy} can refocus this thread.`);
}
}
const label = focusTarget.label || token;
const binding = await threadBindings.bindTarget({
threadId: currentThreadId || undefined,
channelId: parentChannelId,
createThread: !currentThreadId,
threadName: resolveThreadBindingThreadName({
agentId: focusTarget.agentId,
label,
}),
targetKind: focusTarget.targetKind,
targetSessionKey: focusTarget.targetSessionKey,
agentId: focusTarget.agentId,
label,
boundBy: senderId || "unknown",
introText: resolveThreadBindingIntroText({
agentId: focusTarget.agentId,
label,
sessionTtlMs: threadBindings.getSessionTtlMs(),
}),
});
const acpMeta =
focusTarget.targetKind === "acp"
? readAcpSessionEntry({
cfg: params.cfg,
sessionKey: focusTarget.targetSessionKey,
})?.acp
: undefined;
const placement = currentThreadId ? "current" : "child";
if (!capabilities.placements.includes(placement)) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
}
const conversationId = currentThreadId || parentChannelId;
if (!conversationId) {
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
}
if (!binding) {
let binding;
try {
binding = await bindingService.bind({
targetSessionKey: focusTarget.targetSessionKey,
targetKind: focusTarget.targetKind === "acp" ? "session" : "subagent",
conversation: {
channel: "discord",
accountId,
conversationId,
},
placement,
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: focusTarget.agentId,
label,
}),
agentId: focusTarget.agentId,
label,
boundBy: senderId || "unknown",
introText: resolveThreadBindingIntroText({
agentId: focusTarget.agentId,
label,
sessionTtlMs: resolveDiscordThreadBindingSessionTtlMs({
cfg: params.cfg,
accountId,
}),
sessionCwd: focusTarget.targetKind === "acp" ? resolveAcpSessionCwd(acpMeta) : undefined,
sessionDetails:
focusTarget.targetKind === "acp"
? resolveAcpThreadSessionDetailLines({
sessionKey: focusTarget.targetSessionKey,
meta: acpMeta,
})
: [],
}),
},
});
} catch {
return stopWithText("⚠️ Failed to bind a Discord thread to the target session.");
}
const actionText = currentThreadId
? `bound this thread to ${binding.targetSessionKey}`
: `created thread ${binding.threadId} and bound it to ${binding.targetSessionKey}`;
return stopWithText(`${actionText} (${binding.targetKind}).`);
: `created thread ${binding.conversation.conversationId} and bound it to ${binding.targetSessionKey}`;
return stopWithText(`${actionText} (${focusTarget.targetKind}).`);
}

View File

@@ -126,6 +126,7 @@ export async function resolveCommandsSystemPromptBundle(
skillsPrompt,
heartbeatPrompt: undefined,
ttsHint,
acpEnabled: params.cfg?.acp?.enabled !== false,
runtimeInfo,
sandboxInfo,
memoryCitationsMode: params.cfg?.memory?.citations,

View File

@@ -1,16 +1,9 @@
import { formatCliCommand } from "../../cli/command-format.js";
import { SYSTEM_MARK, prefixSystemMessage } from "../../infra/system-message.js";
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
export const SYSTEM_MARK = "⚙️";
export const formatDirectiveAck = (text: string): string => {
if (!text) {
return text;
}
if (text.startsWith(SYSTEM_MARK)) {
return text;
}
return `${SYSTEM_MARK} ${text}`;
return prefixSystemMessage(text);
};
export const formatOptionsLine = (options: string) => `Options: ${options}.`;

View File

@@ -0,0 +1,379 @@
import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js";
import { formatAcpRuntimeErrorText } from "../../acp/runtime/error-text.js";
import { toAcpRuntimeError } from "../../acp/runtime/errors.js";
import { resolveAcpThreadSessionDetailLines } from "../../acp/runtime/session-identifiers.js";
import {
isSessionIdentityPending,
resolveSessionIdentityFromMeta,
} from "../../acp/runtime/session-identity.js";
import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { TtsAutoMode } from "../../config/types.tts.js";
import { logVerbose } from "../../globals.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { generateSecureUuid } from "../../infra/secure-random.js";
import { prefixSystemMessage } from "../../infra/system-message.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { maybeApplyTtsToPayload, resolveTtsConfig } from "../../tts/tts.js";
import {
isCommandEnabled,
maybeResolveTextAlias,
shouldHandleTextCommands,
} from "../commands-registry.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { createAcpReplyProjector } from "./acp-projector.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { routeReply } from "./route-reply.js";
type DispatchProcessedRecorder = (
outcome: "completed" | "skipped" | "error",
opts?: {
reason?: string;
error?: string;
},
) => void;
function resolveFirstContextText(
ctx: FinalizedMsgContext,
keys: Array<"BodyForAgent" | "BodyForCommands" | "CommandBody" | "RawBody" | "Body">,
): string {
for (const key of keys) {
const value = ctx[key];
if (typeof value === "string") {
return value;
}
}
return "";
}
function resolveAcpPromptText(ctx: FinalizedMsgContext): string {
return resolveFirstContextText(ctx, [
"BodyForAgent",
"BodyForCommands",
"CommandBody",
"RawBody",
"Body",
]).trim();
}
function resolveCommandCandidateText(ctx: FinalizedMsgContext): string {
return resolveFirstContextText(ctx, ["CommandBody", "BodyForCommands", "RawBody", "Body"]).trim();
}
export function shouldBypassAcpDispatchForCommand(
ctx: FinalizedMsgContext,
cfg: OpenClawConfig,
): boolean {
const candidate = resolveCommandCandidateText(ctx);
if (!candidate) {
return false;
}
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: ctx.Surface ?? ctx.Provider ?? "",
commandSource: ctx.CommandSource,
});
if (maybeResolveTextAlias(candidate, cfg) != null) {
return allowTextCommands;
}
const normalized = candidate.trim();
if (!normalized.startsWith("!")) {
return false;
}
if (!ctx.CommandAuthorized) {
return false;
}
if (!isCommandEnabled(cfg, "bash")) {
return false;
}
return allowTextCommands;
}
function resolveAcpRequestId(ctx: FinalizedMsgContext): string {
const id = ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
if (typeof id === "string" && id.trim()) {
return id.trim();
}
if (typeof id === "number" || typeof id === "bigint") {
return String(id);
}
return generateSecureUuid();
}
function hasBoundConversationForSession(params: {
sessionKey: string;
channelRaw: string | undefined;
accountIdRaw: string | undefined;
}): boolean {
const channel = String(params.channelRaw ?? "")
.trim()
.toLowerCase();
if (!channel) {
return false;
}
const accountId = String(params.accountIdRaw ?? "")
.trim()
.toLowerCase();
const normalizedAccountId = accountId || "default";
const bindingService = getSessionBindingService();
const bindings = bindingService.listBySession(params.sessionKey);
return bindings.some((binding) => {
const bindingChannel = String(binding.conversation.channel ?? "")
.trim()
.toLowerCase();
const bindingAccountId = String(binding.conversation.accountId ?? "")
.trim()
.toLowerCase();
const conversationId = String(binding.conversation.conversationId ?? "").trim();
return (
bindingChannel === channel &&
(bindingAccountId || "default") === normalizedAccountId &&
conversationId.length > 0
);
});
}
export type AcpDispatchAttemptResult = {
queuedFinal: boolean;
counts: Record<ReplyDispatchKind, number>;
};
export async function tryDispatchAcpReply(params: {
ctx: FinalizedMsgContext;
cfg: OpenClawConfig;
dispatcher: ReplyDispatcher;
sessionKey?: string;
inboundAudio: boolean;
sessionTtsAuto?: TtsAutoMode;
ttsChannel?: string;
shouldRouteToOriginating: boolean;
originatingChannel?: string;
originatingTo?: string;
shouldSendToolSummaries: boolean;
bypassForCommand: boolean;
recordProcessed: DispatchProcessedRecorder;
markIdle: (reason: string) => void;
}): Promise<AcpDispatchAttemptResult | null> {
const sessionKey = params.sessionKey?.trim();
if (!sessionKey || params.bypassForCommand) {
return null;
}
const acpManager = getAcpSessionManager();
const acpResolution = acpManager.resolveSession({
cfg: params.cfg,
sessionKey,
});
if (acpResolution.kind === "none") {
return null;
}
const routedCounts: Record<ReplyDispatchKind, number> = {
tool: 0,
block: 0,
final: 0,
};
let queuedFinal = false;
let acpAccumulatedBlockText = "";
let acpBlockCount = 0;
const deliverAcpPayload = async (
kind: ReplyDispatchKind,
payload: ReplyPayload,
): Promise<boolean> => {
if (kind === "block" && payload.text?.trim()) {
if (acpAccumulatedBlockText.length > 0) {
acpAccumulatedBlockText += "\n";
}
acpAccumulatedBlockText += payload.text;
acpBlockCount += 1;
}
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg: params.cfg,
channel: params.ttsChannel,
kind,
inboundAudio: params.inboundAudio,
ttsAuto: params.sessionTtsAuto,
});
if (params.shouldRouteToOriginating && params.originatingChannel && params.originatingTo) {
const result = await routeReply({
payload: ttsPayload,
channel: params.originatingChannel,
to: params.originatingTo,
sessionKey: params.ctx.SessionKey,
accountId: params.ctx.AccountId,
threadId: params.ctx.MessageThreadId,
cfg: params.cfg,
});
if (!result.ok) {
logVerbose(
`dispatch-acp: route-reply (acp/${kind}) failed: ${result.error ?? "unknown error"}`,
);
return false;
}
routedCounts[kind] += 1;
return true;
}
if (kind === "tool") {
return params.dispatcher.sendToolResult(ttsPayload);
}
if (kind === "block") {
return params.dispatcher.sendBlockReply(ttsPayload);
}
return params.dispatcher.sendFinalReply(ttsPayload);
};
const promptText = resolveAcpPromptText(params.ctx);
if (!promptText) {
const counts = params.dispatcher.getQueuedCounts();
counts.tool += routedCounts.tool;
counts.block += routedCounts.block;
counts.final += routedCounts.final;
params.recordProcessed("completed", { reason: "acp_empty_prompt" });
params.markIdle("message_completed");
return { queuedFinal: false, counts };
}
const identityPendingBeforeTurn = isSessionIdentityPending(
resolveSessionIdentityFromMeta(acpResolution.kind === "ready" ? acpResolution.meta : undefined),
);
const shouldEmitResolvedIdentityNotice =
identityPendingBeforeTurn &&
(Boolean(params.ctx.MessageThreadId != null && String(params.ctx.MessageThreadId).trim()) ||
hasBoundConversationForSession({
sessionKey,
channelRaw: params.ctx.OriginatingChannel ?? params.ctx.Surface ?? params.ctx.Provider,
accountIdRaw: params.ctx.AccountId,
}));
const resolvedAcpAgent =
acpResolution.kind === "ready"
? (
acpResolution.meta.agent?.trim() ||
params.cfg.acp?.defaultAgent?.trim() ||
resolveAgentIdFromSessionKey(sessionKey)
).trim()
: resolveAgentIdFromSessionKey(sessionKey);
const projector = createAcpReplyProjector({
cfg: params.cfg,
shouldSendToolSummaries: params.shouldSendToolSummaries,
deliver: deliverAcpPayload,
provider: params.ctx.Surface ?? params.ctx.Provider,
accountId: params.ctx.AccountId,
});
const acpDispatchStartedAt = Date.now();
try {
const dispatchPolicyError = resolveAcpDispatchPolicyError(params.cfg);
if (dispatchPolicyError) {
throw dispatchPolicyError;
}
if (acpResolution.kind === "stale") {
throw acpResolution.error;
}
const agentPolicyError = resolveAcpAgentPolicyError(params.cfg, resolvedAcpAgent);
if (agentPolicyError) {
throw agentPolicyError;
}
await acpManager.runTurn({
cfg: params.cfg,
sessionKey,
text: promptText,
mode: "prompt",
requestId: resolveAcpRequestId(params.ctx),
onEvent: async (event) => await projector.onEvent(event),
});
await projector.flush(true);
const ttsMode = resolveTtsConfig(params.cfg).mode ?? "final";
if (ttsMode === "final" && acpBlockCount > 0 && acpAccumulatedBlockText.trim()) {
try {
const ttsSyntheticReply = await maybeApplyTtsToPayload({
payload: { text: acpAccumulatedBlockText },
cfg: params.cfg,
channel: params.ttsChannel,
kind: "final",
inboundAudio: params.inboundAudio,
ttsAuto: params.sessionTtsAuto,
});
if (ttsSyntheticReply.mediaUrl) {
const delivered = await deliverAcpPayload("final", {
mediaUrl: ttsSyntheticReply.mediaUrl,
audioAsVoice: ttsSyntheticReply.audioAsVoice,
});
queuedFinal = queuedFinal || delivered;
}
} catch (err) {
logVerbose(
`dispatch-acp: accumulated ACP block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (shouldEmitResolvedIdentityNotice) {
const currentMeta = readAcpSessionEntry({
cfg: params.cfg,
sessionKey,
})?.acp;
const identityAfterTurn = resolveSessionIdentityFromMeta(currentMeta);
if (!isSessionIdentityPending(identityAfterTurn)) {
const resolvedDetails = resolveAcpThreadSessionDetailLines({
sessionKey,
meta: currentMeta,
});
if (resolvedDetails.length > 0) {
const delivered = await deliverAcpPayload("final", {
text: prefixSystemMessage(["Session ids resolved.", ...resolvedDetails].join("\n")),
});
queuedFinal = queuedFinal || delivered;
}
}
}
const counts = params.dispatcher.getQueuedCounts();
counts.tool += routedCounts.tool;
counts.block += routedCounts.block;
counts.final += routedCounts.final;
const acpStats = acpManager.getObservabilitySnapshot(params.cfg);
logVerbose(
`acp-dispatch: session=${sessionKey} outcome=ok latencyMs=${Date.now() - acpDispatchStartedAt} queueDepth=${acpStats.turns.queueDepth} activeRuntimes=${acpStats.runtimeCache.activeSessions}`,
);
params.recordProcessed("completed", { reason: "acp_dispatch" });
params.markIdle("message_completed");
return { queuedFinal, counts };
} catch (err) {
await projector.flush(true);
const acpError = toAcpRuntimeError({
error: err,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP turn failed before completion.",
});
const delivered = await deliverAcpPayload("final", {
text: formatAcpRuntimeErrorText(acpError),
isError: true,
});
queuedFinal = queuedFinal || delivered;
const counts = params.dispatcher.getQueuedCounts();
counts.tool += routedCounts.tool;
counts.block += routedCounts.block;
counts.final += routedCounts.final;
const acpStats = acpManager.getObservabilitySnapshot(params.cfg);
logVerbose(
`acp-dispatch: session=${sessionKey} outcome=error code=${acpError.code} latencyMs=${Date.now() - acpDispatchStartedAt} queueDepth=${acpStats.turns.queueDepth} activeRuntimes=${acpStats.runtimeCache.activeSessions}`,
);
params.recordProcessed("completed", {
reason: `acp_error:${acpError.code.toLowerCase()}`,
});
params.markIdle("message_completed");
return { queuedFinal, counts };
}
}

View File

@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
import type { MsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -30,6 +32,46 @@ const internalHookMocks = vi.hoisted(() => ({
createInternalHookEvent: vi.fn(),
triggerInternalHook: vi.fn(async () => {}),
}));
const acpMocks = vi.hoisted(() => ({
listAcpSessionEntries: vi.fn(async () => []),
readAcpSessionEntry: vi.fn<() => unknown>(() => null),
upsertAcpSessionMeta: vi.fn(async () => null),
requireAcpRuntimeBackend: vi.fn<() => unknown>(),
}));
const sessionBindingMocks = vi.hoisted(() => ({
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
}));
const ttsMocks = vi.hoisted(() => {
const state = {
synthesizeFinalAudio: false,
};
return {
state,
maybeApplyTtsToPayload: vi.fn(async (paramsUnknown: unknown) => {
const params = paramsUnknown as {
payload: ReplyPayload;
kind: "tool" | "block" | "final";
};
if (
state.synthesizeFinalAudio &&
params.kind === "final" &&
typeof params.payload?.text === "string" &&
params.payload.text.trim()
) {
return {
...params.payload,
mediaUrl: "https://example.com/tts-synth.opus",
audioAsVoice: true,
};
}
return params.payload;
}),
normalizeTtsAutoMode: vi.fn((value: unknown) =>
typeof value === "string" ? value : undefined,
),
resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
};
});
vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) =>
@@ -64,9 +106,46 @@ vi.mock("../../hooks/internal-hooks.js", () => ({
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
triggerInternalHook: internalHookMocks.triggerInternalHook,
}));
vi.mock("../../acp/runtime/session-meta.js", () => ({
listAcpSessionEntries: acpMocks.listAcpSessionEntries,
readAcpSessionEntry: acpMocks.readAcpSessionEntry,
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
}));
vi.mock("../../acp/runtime/registry.js", () => ({
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
}));
vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../infra/outbound/session-binding-service.js")>();
return {
...actual,
getSessionBindingService: () => ({
bind: vi.fn(async () => {
throw new Error("bind not mocked");
}),
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
})),
listBySession: (targetSessionKey: string) =>
sessionBindingMocks.listBySession(targetSessionKey),
resolveByConversation: vi.fn(() => null),
touch: vi.fn(),
unbind: vi.fn(async () => []),
}),
};
});
vi.mock("../../tts/tts.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
const noAbortResult = { handled: false, aborted: false } as const;
const emptyConfig = {} as OpenClawConfig;
@@ -87,6 +166,26 @@ function setNoAbort() {
mocks.tryFastAbortFromMessage.mockResolvedValue(noAbortResult);
}
function createAcpRuntime(events: Array<Record<string, unknown>>) {
return {
ensureSession: vi.fn(
async (input: { sessionKey: string; mode: string; agent: string }) =>
({
sessionKey: input.sessionKey,
backend: "acpx",
runtimeSessionName: `${input.sessionKey}:${input.mode}`,
}) as { sessionKey: string; backend: string; runtimeSessionName: string },
),
runTurn: vi.fn(async function* () {
for (const event of events) {
yield event;
}
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
}
function firstToolResultPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined {
return (dispatcher.sendToolResult as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as
| ReplyPayload
@@ -106,7 +205,9 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
describe("dispatchReplyFromConfig", () => {
beforeEach(() => {
acpManagerTesting.resetAcpSessionManagerForTests();
resetInboundDedupe();
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
diagnosticMocks.logMessageQueued.mockClear();
diagnosticMocks.logMessageProcessed.mockClear();
diagnosticMocks.logSessionStateChange.mockClear();
@@ -116,6 +217,20 @@ describe("dispatchReplyFromConfig", () => {
internalHookMocks.createInternalHookEvent.mockClear();
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
internalHookMocks.triggerInternalHook.mockClear();
acpMocks.readAcpSessionEntry.mockReset();
acpMocks.readAcpSessionEntry.mockReturnValue(null);
acpMocks.upsertAcpSessionMeta.mockReset();
acpMocks.upsertAcpSessionMeta.mockResolvedValue(null);
acpMocks.requireAcpRuntimeBackend.mockReset();
sessionBindingMocks.listBySession.mockReset();
sessionBindingMocks.listBySession.mockReturnValue([]);
ttsMocks.state.synthesizeFinalAudio = false;
ttsMocks.maybeApplyTtsToPayload.mockClear();
ttsMocks.normalizeTtsAutoMode.mockClear();
ttsMocks.resolveTtsConfig.mockClear();
ttsMocks.resolveTtsConfig.mockReturnValue({
mode: "final",
});
});
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
setNoAbort();
@@ -367,6 +482,811 @@ describe("dispatchReplyFromConfig", () => {
});
});
it("routes ACP sessions through the runtime branch and streams block replies", async () => {
setNoAbort();
const runtime = createAcpRuntime([
{ type: "text_delta", text: "hello " },
{ type: "text_delta", text: "world" },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
stream: { coalesceIdleMs: 0, maxChunkChars: 128 },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "write a test",
});
const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).not.toHaveBeenCalled();
expect(runtime.ensureSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:codex-acp:session-1",
agent: "codex",
mode: "persistent",
}),
);
const blockCalls = (dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mock.calls;
expect(blockCalls.length).toBeGreaterThan(0);
const streamedText = blockCalls.map((call) => (call[0] as ReplyPayload).text ?? "").join("");
expect(streamedText).toContain("hello");
expect(streamedText).toContain("world");
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("posts a one-time resolved-session-id notice in thread after the first ACP turn", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "text_delta", text: "hello" }, { type: "done" }]);
const pendingAcp = {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
identity: {
state: "pending" as const,
source: "ensure" as const,
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent" as const,
state: "idle" as const,
lastActivityAt: Date.now(),
};
const resolvedAcp = {
...pendingAcp,
identity: {
...pendingAcp.identity,
state: "resolved" as const,
source: "status" as const,
},
};
acpMocks.readAcpSessionEntry.mockImplementation(() => {
const runTurnStarted = runtime.runTurn.mock.calls.length > 0;
return {
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: runTurnStarted ? resolvedAcp : pendingAcp,
};
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
MessageThreadId: "thread-1",
BodyForAgent: "show ids",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver: vi.fn() });
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
expect(finalCalls.length).toBe(1);
const finalPayload = finalCalls[0]?.[0] as ReplyPayload | undefined;
expect(finalPayload?.text).toContain("Session ids resolved");
expect(finalPayload?.text).toContain("agent session id: inner-123");
expect(finalPayload?.text).toContain("acpx session id: acpx-123");
expect(finalPayload?.text).toContain("codex resume inner-123");
});
it("posts resolved-session-id notice when ACP session is bound even without MessageThreadId", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "text_delta", text: "hello" }, { type: "done" }]);
const pendingAcp = {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
identity: {
state: "pending" as const,
source: "ensure" as const,
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent" as const,
state: "idle" as const,
lastActivityAt: Date.now(),
};
const resolvedAcp = {
...pendingAcp,
identity: {
...pendingAcp.identity,
state: "resolved" as const,
source: "status" as const,
},
};
acpMocks.readAcpSessionEntry.mockImplementation(() => {
const runTurnStarted = runtime.runTurn.mock.calls.length > 0;
return {
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: runTurnStarted ? resolvedAcp : pendingAcp,
};
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
sessionBindingMocks.listBySession.mockReturnValue([
{
bindingId: "default:thread-1",
targetSessionKey: "agent:codex-acp:session-1",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
status: "active",
boundAt: Date.now(),
},
]);
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
AccountId: "default",
SessionKey: "agent:codex-acp:session-1",
MessageThreadId: undefined,
BodyForAgent: "show ids",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver: vi.fn() });
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
expect(finalCalls.length).toBe(1);
const finalPayload = finalCalls[0]?.[0] as ReplyPayload | undefined;
expect(finalPayload?.text).toContain("Session ids resolved");
expect(finalPayload?.text).toContain("agent session id: inner-123");
expect(finalPayload?.text).toContain("acpx session id: acpx-123");
});
it("honors send-policy deny before ACP runtime dispatch", async () => {
setNoAbort();
const runtime = createAcpRuntime([
{ type: "text_delta", text: "should-not-run" },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
session: {
sendPolicy: {
default: "deny",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "write a test",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
expect(runtime.runTurn).not.toHaveBeenCalled();
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("routes ACP slash commands through the normal command pipeline", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
session: {
sendPolicy: {
default: "deny",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
CommandBody: "/acp cancel",
BodyForCommands: "/acp cancel",
BodyForAgent: "/acp cancel",
});
const replyResolver = vi.fn(async () => ({ text: "command output" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(runtime.runTurn).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
text: "command output",
});
});
it("does not bypass ACP slash aliases when text commands are disabled on native surfaces", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
commands: {
text: false,
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
CommandBody: "/acp cancel",
BodyForCommands: "/acp cancel",
BodyForAgent: "/acp cancel",
CommandSource: "text",
});
const replyResolver = vi.fn(async () => ({ text: "should not bypass" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(runtime.runTurn).toHaveBeenCalledTimes(1);
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("does not bypass ACP dispatch for unauthorized bang-prefixed messages", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
session: {
sendPolicy: {
default: "deny",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
CommandBody: "!poll",
BodyForCommands: "!poll",
BodyForAgent: "!poll",
CommandAuthorized: false,
});
const replyResolver = vi.fn(async () => ({ text: "should not bypass" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(runtime.runTurn).not.toHaveBeenCalled();
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("does not bypass ACP dispatch for bang-prefixed messages when text commands are disabled", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
commands: {
text: false,
},
session: {
sendPolicy: {
default: "deny",
},
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
CommandBody: "!poll",
BodyForCommands: "!poll",
BodyForAgent: "!poll",
CommandAuthorized: true,
CommandSource: "text",
});
const replyResolver = vi.fn(async () => ({ text: "should not bypass" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(runtime.runTurn).not.toHaveBeenCalled();
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("coalesces tiny ACP token deltas into normal Discord text spacing", async () => {
setNoAbort();
const runtime = createAcpRuntime([
{ type: "text_delta", text: "What" },
{ type: "text_delta", text: " do" },
{ type: "text_delta", text: " you" },
{ type: "text_delta", text: " want" },
{ type: "text_delta", text: " to" },
{ type: "text_delta", text: " work" },
{ type: "text_delta", text: " on?" },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
stream: { coalesceIdleMs: 0, maxChunkChars: 256 },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "test spacing",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
const blockTexts = (dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mock.calls
.map((call) => ((call[0] as ReplyPayload).text ?? "").trim())
.filter(Boolean);
expect(blockTexts).toEqual(["What do you want to work on?"]);
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("generates final-mode TTS audio after ACP block streaming completes", async () => {
setNoAbort();
ttsMocks.state.synthesizeFinalAudio = true;
const runtime = createAcpRuntime([
{ type: "text_delta", text: "Hello from ACP streaming." },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
stream: { coalesceIdleMs: 0, maxChunkChars: 256 },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "stream this",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
const finalPayload = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as ReplyPayload | undefined;
expect(finalPayload?.mediaUrl).toBe("https://example.com/tts-synth.opus");
expect(finalPayload?.text).toBeUndefined();
});
it("routes ACP block output to originating channel without parent dispatcher duplicates", async () => {
setNoAbort();
mocks.routeReply.mockClear();
const runtime = createAcpRuntime([
{ type: "text_delta", text: "thread chunk" },
{ type: "done" },
]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
stream: { coalesceIdleMs: 0, maxChunkChars: 128 },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:thread-1",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "write a test",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
expect(mocks.routeReply).toHaveBeenCalled();
expect(mocks.routeReply).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
to: "telegram:thread-1",
}),
);
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
});
it("closes oneshot ACP sessions after the turn completes", async () => {
setNoAbort();
const runtime = createAcpRuntime([{ type: "done" }]);
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:oneshot-1",
storeSessionKey: "agent:codex-acp:oneshot-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:oneshot",
mode: "oneshot",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
id: "acpx",
runtime,
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:oneshot-1",
BodyForAgent: "run once",
});
await dispatchReplyFromConfig({ ctx, cfg, dispatcher });
expect(runtime.close).toHaveBeenCalledWith(
expect.objectContaining({
reason: "oneshot-complete",
}),
);
});
it("emits an explicit ACP policy error when dispatch is disabled", async () => {
setNoAbort();
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: false },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "write a test",
});
const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).not.toHaveBeenCalled();
expect(acpMocks.requireAcpRuntimeBackend).not.toHaveBeenCalled();
const finalPayload = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as ReplyPayload | undefined;
expect(finalPayload?.text).toContain("ACP dispatch is disabled by policy");
});
it("fails closed when ACP metadata is missing for an ACP session key", async () => {
setNoAbort();
acpMocks.readAcpSessionEntry.mockReturnValue(null);
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex:acp:session-1",
BodyForAgent: "hello",
});
const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).not.toHaveBeenCalled();
expect(acpMocks.requireAcpRuntimeBackend).not.toHaveBeenCalled();
const finalPayload = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as ReplyPayload | undefined;
expect(finalPayload?.text).toContain("ACP metadata is missing");
});
it("surfaces backend-missing ACP errors in-thread without falling back", async () => {
setNoAbort();
acpMocks.readAcpSessionEntry.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
storeSessionKey: "agent:codex-acp:session-1",
cfg: {},
storePath: "/tmp/mock-sessions.json",
entry: {},
acp: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime:1",
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
throw new AcpRuntimeError(
"ACP_BACKEND_MISSING",
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
);
});
const cfg = {
acp: {
enabled: true,
dispatch: { enabled: true },
},
} as OpenClawConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "discord",
Surface: "discord",
SessionKey: "agent:codex-acp:session-1",
BodyForAgent: "write a test",
});
const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).not.toHaveBeenCalled();
const finalPayload = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as ReplyPayload | undefined;
expect(finalPayload?.text).toContain("ACP error (ACP_BACKEND_MISSING)");
expect(finalPayload?.text).toContain("Install and enable the acpx runtime plugin");
});
it("deduplicates inbound messages by MessageSid and origin", async () => {
setNoAbort();
const cfg = emptyConfig;

View File

@@ -1,6 +1,6 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
@@ -10,11 +10,13 @@ import {
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldBypassAcpDispatchForCommand, tryDispatchAcpReply } from "./dispatch-acp.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
@@ -22,7 +24,6 @@ import { isRoutableChannel, routeReply } from "./route-reply.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase();
const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
@@ -55,24 +56,31 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
return AUDIO_HEADER_RE.test(trimmed);
};
const resolveSessionTtsAuto = (
const resolveSessionStoreEntry = (
ctx: FinalizedMsgContext,
cfg: OpenClawConfig,
): string | undefined => {
): {
sessionKey?: string;
entry?: SessionEntry;
} => {
const targetSessionKey =
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
if (!sessionKey) {
return undefined;
return {};
}
const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
const storePath = resolveStorePath(cfg.session?.store, { agentId });
try {
const store = loadSessionStore(storePath);
const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey];
return normalizeTtsAutoMode(entry?.ttsAuto);
return {
sessionKey,
entry: store[sessionKey.toLowerCase()] ?? store[sessionKey],
};
} catch {
return undefined;
return {
sessionKey,
};
}
};
@@ -147,8 +155,9 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
const sessionStoreEntry = resolveSessionStoreEntry(ctx, cfg);
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
const sessionTtsAuto = normalizeTtsAutoMode(sessionStoreEntry.entry?.ttsAuto);
const hookRunner = getGlobalHookRunner();
// Extract message context for hooks (plugin and internal)
@@ -241,8 +250,9 @@ export async function dispatchReplyFromConfig(params: {
const originatingChannel = ctx.OriginatingChannel;
const originatingTo = ctx.OriginatingTo;
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
const shouldRouteToOriginating =
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
const shouldRouteToOriginating = Boolean(
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface,
);
const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface;
/**
@@ -319,14 +329,57 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts };
}
const bypassAcpForCommand = shouldBypassAcpDispatchForCommand(ctx, cfg);
const sendPolicy = resolveSendPolicy({
cfg,
entry: sessionStoreEntry.entry,
sessionKey: sessionStoreEntry.sessionKey ?? sessionKey,
channel:
sessionStoreEntry.entry?.channel ??
ctx.OriginatingChannel ??
ctx.Surface ??
ctx.Provider ??
undefined,
chatType: sessionStoreEntry.entry?.chatType,
});
if (sendPolicy === "deny" && !bypassAcpForCommand) {
logVerbose(
`Send blocked by policy for session ${sessionStoreEntry.sessionKey ?? sessionKey ?? "unknown"}`,
);
const counts = dispatcher.getQueuedCounts();
recordProcessed("completed", { reason: "send_policy_deny" });
markIdle("message_completed");
return { queuedFinal: false, counts };
}
const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native";
const acpDispatch = await tryDispatchAcpReply({
ctx,
cfg,
dispatcher,
sessionKey,
inboundAudio,
sessionTtsAuto,
ttsChannel,
shouldRouteToOriginating,
originatingChannel,
originatingTo,
shouldSendToolSummaries,
bypassForCommand: bypassAcpForCommand,
recordProcessed,
markIdle,
});
if (acpDispatch) {
return acpDispatch;
}
// Track accumulated block text for TTS generation after streaming completes.
// When block streaming succeeds, there's no final reply, so we need to generate
// TTS audio separately from the accumulated block content.
let accumulatedBlockText = "";
let blockCount = 0;
const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native";
const resolveToolDeliveryPayload = (payload: ReplyPayload): ReplyPayload | null => {
if (shouldSendToolSummaries) {
return payload;

View File

@@ -0,0 +1,84 @@
import { prefixSystemMessage } from "../infra/system-message.js";
const DEFAULT_THREAD_BINDING_FAREWELL_TEXT =
"Session ended. Messages here will no longer be routed.";
function normalizeThreadBindingMessageTtlMs(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return 0;
}
const ttlMs = Math.floor(raw);
if (ttlMs < 0) {
return 0;
}
return ttlMs;
}
export function formatThreadBindingTtlLabel(ttlMs: number): string {
if (ttlMs <= 0) {
return "disabled";
}
if (ttlMs < 60_000) {
return "<1m";
}
const totalMinutes = Math.floor(ttlMs / 60_000);
if (totalMinutes % 60 === 0) {
return `${Math.floor(totalMinutes / 60)}h`;
}
return `${totalMinutes}m`;
}
export function resolveThreadBindingThreadName(params: {
agentId?: string;
label?: string;
}): string {
const label = params.label?.trim();
const base = label || params.agentId?.trim() || "agent";
const raw = `🤖 ${base}`.replace(/\s+/g, " ").trim();
return raw.slice(0, 100);
}
export function resolveThreadBindingIntroText(params: {
agentId?: string;
label?: string;
sessionTtlMs?: number;
sessionCwd?: string;
sessionDetails?: string[];
}): string {
const label = params.label?.trim();
const base = label || params.agentId?.trim() || "agent";
const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent";
const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs);
const cwd = params.sessionCwd?.trim();
const details = (params.sessionDetails ?? [])
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (cwd) {
details.unshift(`cwd: ${cwd}`);
}
const intro =
ttlMs > 0
? `${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`
: `${normalized} session active. Messages here go directly to this session.`;
if (details.length === 0) {
return prefixSystemMessage(intro);
}
return prefixSystemMessage(`${intro}\n${details.join("\n")}`);
}
export function resolveThreadBindingFarewellText(params: {
reason?: string;
farewellText?: string;
sessionTtlMs: number;
}): string {
const custom = params.farewellText?.trim();
if (custom) {
return prefixSystemMessage(custom);
}
if (params.reason === "ttl-expired") {
return prefixSystemMessage(
`Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`,
);
}
return prefixSystemMessage(DEFAULT_THREAD_BINDING_FAREWELL_TEXT);
}

Some files were not shown because too many files have changed in this diff Show More