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

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;