mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:31:37 +00:00
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:
145
src/auto-reply/reply/acp-projector.test.ts
Normal file
145
src/auto-reply/reply/acp-projector.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
140
src/auto-reply/reply/acp-projector.ts
Normal file
140
src/auto-reply/reply/acp-projector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
68
src/auto-reply/reply/block-streaming.test.ts
Normal file
68
src/auto-reply/reply/block-streaming.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
796
src/auto-reply/reply/commands-acp.test.ts
Normal file
796
src/auto-reply/reply/commands-acp.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
83
src/auto-reply/reply/commands-acp.ts
Normal file
83
src/auto-reply/reply/commands-acp.ts
Normal 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());
|
||||
};
|
||||
51
src/auto-reply/reply/commands-acp/context.test.ts
Normal file
51
src/auto-reply/reply/commands-acp/context.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
58
src/auto-reply/reply/commands-acp/context.ts
Normal file
58
src/auto-reply/reply/commands-acp/context.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
203
src/auto-reply/reply/commands-acp/diagnostics.ts
Normal file
203
src/auto-reply/reply/commands-acp/diagnostics.ts
Normal 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"));
|
||||
}
|
||||
588
src/auto-reply/reply/commands-acp/lifecycle.ts
Normal file
588
src/auto-reply/reply/commands-acp/lifecycle.ts
Normal 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"}.`,
|
||||
);
|
||||
}
|
||||
348
src/auto-reply/reply/commands-acp/runtime-options.ts
Normal file
348
src/auto-reply/reply/commands-acp/runtime-options.ts
Normal 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}.`),
|
||||
});
|
||||
}
|
||||
500
src/auto-reply/reply/commands-acp/shared.ts
Normal file
500
src/auto-reply/reply/commands-acp/shared.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/auto-reply/reply/commands-acp/targets.ts
Normal file
90
src/auto-reply/reply/commands-acp/targets.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}).`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}.`;
|
||||
|
||||
379
src/auto-reply/reply/dispatch-acp.ts
Normal file
379
src/auto-reply/reply/dispatch-acp.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user