fix(acpx): lazy-load startup backend

This commit is contained in:
Vincent Koc
2026-04-27 21:46:24 -07:00
parent 996818e6af
commit 6d7901f5c8
13 changed files with 426 additions and 135 deletions

View File

@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
- Subagents/models: persist `sessions_spawn.model` and configured subagent models as child-session model overrides before the first turn, so spawned subagents actually run on the requested provider/model instead of reverting to the target agent default. Fixes #73180. Thanks @danielzinhu99.
- Channels/Telegram: keep webhook-mode local listeners alive and retry Telegram `setWebhook` registration after recoverable startup network failures, so transient Bot API timeouts no longer leave reverse proxies pointing at a closed listener. Fixes #71834. Thanks @jinon86.
- Agents/ACPX: bundle the Codex ACP adapter and launch it from the isolated `CODEX_HOME` wrapper before falling back to npm, so Codex ACP startup no longer depends on live `npx` resolution or the stale `@zed-industries/codex-acp@^0.11.1` range. Fixes #72037; refs #73202. Thanks @jasonftl, @sazora, and @joerod26.
- Agents/ACPX: register the embedded ACP backend at Gateway startup through a lightweight ACP backend SDK path and without importing the heavy ACPX runtime until an ACP session or explicit startup probe needs it, reducing baseline Gateway RSS. Thanks @vincentkoc.
- CLI/update: keep restart health polling when the restarted Gateway is reachable but has not reported its version yet, so macOS service restarts do not fail early with `actual unavailable`. Thanks @ProspectOre.
- Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang.
- Cron/models: fail isolated cron runs closed when an explicit `payload.model` is not allowed or cannot be resolved, so scheduled jobs do not silently fall back to an unrelated agent default or paid route before configured provider proxies such as LiteLLM can run. Fixes #73146. Thanks @oneandrewwang.

View File

@@ -212,6 +212,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/file-lock` | Re-entrant file-lock helpers |
| `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers |
| `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers |
| `plugin-sdk/acp-runtime-backend` | Lightweight ACP backend registration and reply-dispatch helpers for startup-loaded plugins |
| `plugin-sdk/acp-binding-resolve-runtime` | Read-only ACP binding resolution without lifecycle startup imports |
| `plugin-sdk/agent-config-primitives` | Narrow agent runtime config-schema primitives |
| `plugin-sdk/boolean-param` | Loose boolean param reader |

View File

@@ -12,7 +12,7 @@ vi.mock("./register.runtime.js", () => ({
createAcpxRuntimeService: createAcpxRuntimeServiceMock,
}));
vi.mock("./runtime-api.js", () => ({
vi.mock("openclaw/plugin-sdk/acp-runtime-backend", () => ({
tryDispatchAcpReplyHook: tryDispatchAcpReplyHookMock,
}));

View File

@@ -1,12 +1,11 @@
import { tryDispatchAcpReplyHook } from "openclaw/plugin-sdk/acp-runtime-backend";
import { createAcpxRuntimeService } from "./register.runtime.js";
import { tryDispatchAcpReplyHook, type OpenClawPluginApi } from "./runtime-api.js";
import { createAcpxPluginConfigSchema } from "./src/config-schema.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
const plugin = {
id: "acpx",
name: "ACPX Runtime",
description: "Embedded ACP runtime backend with plugin-owned session and transport management.",
configSchema: () => createAcpxPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(
createAcpxRuntimeService({

View File

@@ -1 +1,154 @@
export { createAcpxRuntimeService } from "./src/service.js";
import {
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
type AcpRuntime,
type AcpRuntimeCapabilities,
type AcpRuntimeDoctorReport,
type AcpRuntimeStatus,
} from "openclaw/plugin-sdk/acp-runtime-backend";
import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core";
const ACPX_BACKEND_ID = "acpx";
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
type RealAcpxServiceModule = typeof import("./src/service.js");
type CreateAcpxRuntimeServiceParams = NonNullable<
Parameters<RealAcpxServiceModule["createAcpxRuntimeService"]>[0]
>;
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
doctor?(): Promise<AcpRuntimeDoctorReport>;
isHealthy(): boolean;
};
type DeferredServiceState = {
ctx: OpenClawPluginServiceContext | null;
params: CreateAcpxRuntimeServiceParams;
realRuntime: AcpxRuntimeLike | null;
realService: OpenClawPluginService | null;
startPromise: Promise<AcpxRuntimeLike> | null;
};
let serviceModulePromise: Promise<RealAcpxServiceModule> | null = null;
function loadServiceModule(): Promise<RealAcpxServiceModule> {
serviceModulePromise ??= import("./src/service.js");
return serviceModulePromise;
}
function shouldRunStartupProbe(env: NodeJS.ProcessEnv = process.env): boolean {
return env[ENABLE_STARTUP_PROBE_ENV] === "1";
}
async function startRealService(state: DeferredServiceState): Promise<AcpxRuntimeLike> {
if (state.realRuntime) {
return state.realRuntime;
}
if (!state.ctx) {
throw new Error("ACPX runtime service is not started");
}
state.startPromise ??= (async () => {
const { createAcpxRuntimeService } = await loadServiceModule();
const service = createAcpxRuntimeService(state.params);
state.realService = service;
await service.start(state.ctx as OpenClawPluginServiceContext);
const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID);
if (!backend?.runtime) {
throw new Error("ACPX runtime service did not register an ACP backend");
}
state.realRuntime = backend.runtime as AcpxRuntimeLike;
return state.realRuntime;
})();
return await state.startPromise;
}
function createDeferredRuntime(state: DeferredServiceState): AcpxRuntimeLike {
return {
async ensureSession(input) {
return await (await startRealService(state)).ensureSession(input);
},
async *runTurn(input) {
yield* (await startRealService(state)).runTurn(input);
},
async getCapabilities(input): Promise<AcpRuntimeCapabilities> {
const runtime = await startRealService(state);
return (await runtime.getCapabilities?.(input)) ?? { controls: [] };
},
async getStatus(input): Promise<AcpRuntimeStatus> {
const runtime = await startRealService(state);
return (await runtime.getStatus?.(input)) ?? {};
},
async setMode(input) {
await (await startRealService(state)).setMode?.(input);
},
async setConfigOption(input) {
await (await startRealService(state)).setConfigOption?.(input);
},
async doctor(): Promise<AcpRuntimeDoctorReport> {
const runtime = await startRealService(state);
return (await runtime.doctor?.()) ?? { ok: true, message: "ok" };
},
async prepareFreshSession(input) {
await (await startRealService(state)).prepareFreshSession?.(input);
},
async cancel(input) {
await (await startRealService(state)).cancel(input);
},
async close(input) {
await (await startRealService(state)).close(input);
},
async probeAvailability() {
await (await startRealService(state)).probeAvailability();
},
isHealthy() {
return state.realRuntime?.isHealthy() ?? false;
},
};
}
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
): OpenClawPluginService {
const state: DeferredServiceState = {
ctx: null,
params,
realRuntime: null,
realService: null,
startPromise: null,
};
return {
id: "acpx-runtime",
async start(ctx) {
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
return;
}
state.ctx = ctx;
if (shouldRunStartupProbe()) {
await startRealService(state);
return;
}
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
runtime: createDeferredRuntime(state),
});
ctx.logger.info("embedded acpx runtime backend registered lazily");
},
async stop(ctx) {
if (state.realService) {
await state.realService.stop?.(ctx);
} else {
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
}
state.ctx = null;
state.realRuntime = null;
state.realService = null;
state.startPromise = null;
},
};
}

View File

@@ -1,11 +1,11 @@
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime";
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime-backend";
export {
AcpRuntimeError,
getAcpRuntimeBackend,
tryDispatchAcpReplyHook,
registerAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "openclaw/plugin-sdk/acp-runtime";
} from "openclaw/plugin-sdk/acp-runtime-backend";
export type {
AcpRuntime,
AcpRuntimeCapabilities,
@@ -17,7 +17,7 @@ export type {
AcpRuntimeTurnAttachment,
AcpRuntimeTurnInput,
AcpSessionUpdateTag,
} from "openclaw/plugin-sdk/acp-runtime";
} from "openclaw/plugin-sdk/acp-runtime-backend";
export type {
OpenClawPluginApi,
OpenClawPluginConfigSchema,

View File

@@ -11,6 +11,32 @@ const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
),
}));
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
vi.hoisted(() => ({
acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
return {
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
ensureSession: vi.fn(async () => ({
backend: "acpx",
runtimeSessionName: "agent:codex:acp:test",
sessionKey: "agent:codex:acp:test",
})),
getCapabilities: vi.fn(async () => ({ controls: [] })),
getStatus: vi.fn(async () => ({ summary: "ready" })),
isHealthy: vi.fn(() => true),
prepareFreshSession: vi.fn(async () => {}),
probeAvailability: vi.fn(async () => {}),
runTurn: vi.fn(async function* () {}),
setConfigOption: vi.fn(async () => {}),
setMode: vi.fn(async () => {}),
__options: options,
};
}),
createAgentRegistryMock: vi.fn(() => ({})),
createFileSessionStoreMock: vi.fn(() => ({})),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
@@ -24,9 +50,9 @@ vi.mock("../runtime-api.js", () => ({
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: function AcpxRuntime() {},
createAgentRegistry: vi.fn(() => ({})),
createFileSessionStore: vi.fn(() => ({})),
AcpxRuntime: acpxRuntimeConstructorMock,
createAgentRegistry: createAgentRegistryMock,
createFileSessionStore: createFileSessionStoreMock,
}));
vi.mock("./codex-auth-bridge.js", () => ({
@@ -47,6 +73,9 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
prepareAcpxCodexAuthConfigMock.mockClear();
acpxRuntimeConstructorMock.mockClear();
createAgentRegistryMock.mockClear();
createFileSessionStoreMock.mockClear();
delete process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
@@ -126,6 +155,28 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("registers the default backend without importing ACPX runtime until first use", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const service = createAcpxRuntimeService();
await service.start(ctx);
const backend = getAcpRuntimeBackend("acpx");
expect(backend?.runtime).toBeDefined();
expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled();
await backend?.runtime.ensureSession({
agent: "codex",
mode: "oneshot",
sessionKey: "agent:codex:acp:test",
});
expect(acpxRuntimeConstructorMock).toHaveBeenCalledOnce();
await service.stop?.(ctx);
});
it("can run the embedded runtime probe at startup when explicitly enabled", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
const workspaceDir = await makeTempDir();

View File

@@ -14,12 +14,6 @@ import {
toAcpMcpServers,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import {
ACPX_BACKEND_ID,
AcpxRuntime,
createAgentRegistry,
createFileSessionStore,
} from "./runtime.js";
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
@@ -32,6 +26,10 @@ type AcpxRuntimeLike = AcpRuntime & {
};
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
const ACPX_BACKEND_ID = "acpx";
type AcpxRuntimeModule = typeof import("./runtime.js");
let runtimeModulePromise: Promise<AcpxRuntimeModule> | null = null;
type AcpxRuntimeFactoryParams = {
pluginConfig: ResolvedAcpxPluginConfig;
@@ -40,27 +38,83 @@ type AcpxRuntimeFactoryParams = {
type CreateAcpxRuntimeServiceParams = {
pluginConfig?: unknown;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike | Promise<AcpxRuntimeLike>;
};
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime({
cwd: params.pluginConfig.cwd,
sessionStore: createFileSessionStore({
stateDir: params.pluginConfig.stateDir,
}),
agentRegistry: createAgentRegistry({
overrides: params.pluginConfig.agents,
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs:
params.pluginConfig.timeoutSeconds != null
? params.pluginConfig.timeoutSeconds * 1_000
: undefined,
});
function loadRuntimeModule(): Promise<AcpxRuntimeModule> {
runtimeModulePromise ??= import("./runtime.js");
return runtimeModulePromise;
}
function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
let runtime: AcpxRuntimeLike | null = null;
let runtimePromise: Promise<AcpxRuntimeLike> | null = null;
async function resolveRuntime(): Promise<AcpxRuntimeLike> {
if (runtime) {
return runtime;
}
runtimePromise ??= loadRuntimeModule().then((module) => {
runtime = new module.AcpxRuntime({
cwd: params.pluginConfig.cwd,
sessionStore: module.createFileSessionStore({
stateDir: params.pluginConfig.stateDir,
}),
agentRegistry: module.createAgentRegistry({
overrides: params.pluginConfig.agents,
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs:
params.pluginConfig.timeoutSeconds != null
? params.pluginConfig.timeoutSeconds * 1_000
: undefined,
}) as AcpxRuntimeLike;
return runtime;
});
return await runtimePromise;
}
return {
async ensureSession(input) {
return await (await resolveRuntime()).ensureSession(input);
},
async *runTurn(input) {
yield* (await resolveRuntime()).runTurn(input);
},
async getCapabilities(input) {
return (await (await resolveRuntime()).getCapabilities?.(input)) ?? { controls: [] };
},
async getStatus(input) {
return (await (await resolveRuntime()).getStatus?.(input)) ?? {};
},
async setMode(input) {
await (await resolveRuntime()).setMode?.(input);
},
async setConfigOption(input) {
await (await resolveRuntime()).setConfigOption?.(input);
},
async doctor() {
return (await (await resolveRuntime()).doctor?.()) ?? { ok: true, message: "ok" };
},
async prepareFreshSession(input) {
await (await resolveRuntime()).prepareFreshSession?.(input);
},
async cancel(input) {
await (await resolveRuntime()).cancel(input);
},
async close(input) {
await (await resolveRuntime()).close(input);
},
async probeAvailability() {
await (await resolveRuntime()).probeAvailability();
},
isHealthy() {
return runtime?.isHealthy() ?? false;
},
};
}
function warnOnIgnoredLegacyCompatibilityConfig(params: {
@@ -167,11 +221,15 @@ export function createAcpxRuntimeService(
logger: ctx.logger,
});
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
runtime = runtimeFactory({
pluginConfig,
logger: ctx.logger,
});
runtime = params.runtimeFactory
? await params.runtimeFactory({
pluginConfig,
logger: ctx.logger,
})
: createLazyDefaultRuntime({
pluginConfig,
logger: ctx.logger,
});
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,

View File

@@ -474,6 +474,10 @@
"types": "./dist/plugin-sdk/acp-runtime.d.ts",
"default": "./dist/plugin-sdk/acp-runtime.js"
},
"./plugin-sdk/acp-runtime-backend": {
"types": "./dist/plugin-sdk/acp-runtime-backend.d.ts",
"default": "./dist/plugin-sdk/acp-runtime-backend.js"
},
"./plugin-sdk/acp-binding-runtime": {
"types": "./dist/plugin-sdk/acp-binding-runtime.d.ts",
"default": "./dist/plugin-sdk/acp-binding-runtime.js"

View File

@@ -12,6 +12,10 @@
"types": "./dist/src/plugin-sdk/acp-runtime.d.ts",
"default": "./src/acp-runtime.ts"
},
"./acp-runtime-backend": {
"types": "./dist/src/plugin-sdk/acp-runtime-backend.d.ts",
"default": "./src/acp-runtime-backend.ts"
},
"./async-lock-runtime": {
"types": "./dist/src/plugin-sdk/async-lock-runtime.d.ts",
"default": "./src/async-lock-runtime.ts"

View File

@@ -102,6 +102,7 @@
"process-runtime",
"windows-spawn",
"acp-runtime",
"acp-runtime-backend",
"acp-binding-runtime",
"acp-binding-resolve-runtime",
"lazy-runtime",

View File

@@ -0,0 +1,112 @@
// Lightweight ACP runtime backend helpers for startup-loaded plugins.
import type {
PluginHookReplyDispatchContext,
PluginHookReplyDispatchEvent,
PluginHookReplyDispatchResult,
} from "../plugins/types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js";
export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js";
export {
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
requireAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "../acp/runtime/registry.js";
export type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntimeEnsureInput,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurnAttachment,
AcpRuntimeTurnInput,
AcpSessionUpdateTag,
} from "../acp/runtime/types.js";
let dispatchAcpRuntimePromise: Promise<
typeof import("../auto-reply/reply/dispatch-acp.runtime.js")
> | null = null;
function loadDispatchAcpRuntime() {
dispatchAcpRuntimePromise ??= import("../auto-reply/reply/dispatch-acp.runtime.js");
return dispatchAcpRuntimePromise;
}
function hasExplicitCommandCandidate(ctx: PluginHookReplyDispatchEvent["ctx"]): boolean {
const commandBody = normalizeOptionalString(ctx.CommandBody);
if (commandBody) {
return true;
}
const normalized = normalizeOptionalString(ctx.BodyForCommands);
if (!normalized) {
return false;
}
return normalized.startsWith("!") || normalized.startsWith("/");
}
export async function tryDispatchAcpReplyHook(
event: PluginHookReplyDispatchEvent,
ctx: PluginHookReplyDispatchContext,
): Promise<PluginHookReplyDispatchResult | void> {
// Under sendPolicy: "deny", ACP-bound sessions still need their turns to flow
// through acpManager.runTurn so session state, tool calls, and memory stay
// consistent. Delivery suppression is handled by the ACP delivery path.
if (
event.sendPolicy === "deny" &&
!event.suppressUserDelivery &&
!hasExplicitCommandCandidate(event.ctx) &&
!event.isTailDispatch
) {
return;
}
const runtime = await loadDispatchAcpRuntime();
const bypassForCommand = await runtime.shouldBypassAcpDispatchForCommand(event.ctx, ctx.cfg);
if (
event.sendPolicy === "deny" &&
!event.suppressUserDelivery &&
!bypassForCommand &&
!event.isTailDispatch
) {
return;
}
const result = await runtime.tryDispatchAcpReply({
ctx: event.ctx,
cfg: ctx.cfg,
dispatcher: ctx.dispatcher,
runId: event.runId,
sessionKey: event.sessionKey,
images: event.images,
abortSignal: ctx.abortSignal,
inboundAudio: event.inboundAudio,
sessionTtsAuto: event.sessionTtsAuto,
ttsChannel: event.ttsChannel,
suppressUserDelivery: event.suppressUserDelivery,
shouldRouteToOriginating: event.shouldRouteToOriginating,
originatingChannel: event.originatingChannel,
originatingTo: event.originatingTo,
shouldSendToolSummaries: event.shouldSendToolSummaries,
bypassForCommand,
onReplyStart: ctx.onReplyStart,
recordProcessed: ctx.recordProcessed,
markIdle: ctx.markIdle,
});
if (!result) {
return;
}
return {
handled: true,
queuedFinal: result.queuedFinal,
counts: result.counts,
};
}

View File

@@ -2,12 +2,6 @@
import { __testing as managerTesting, getAcpSessionManager } from "../acp/control-plane/manager.js";
import { __testing as registryTesting } from "../acp/runtime/registry.js";
import type {
PluginHookReplyDispatchContext,
PluginHookReplyDispatchEvent,
PluginHookReplyDispatchResult,
} from "../plugins/types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
export { getAcpSessionManager };
export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js";
@@ -32,94 +26,7 @@ export type {
} from "../acp/runtime/types.js";
export { readAcpSessionEntry } from "../acp/runtime/session-meta.js";
export type { AcpSessionStoreEntry } from "../acp/runtime/session-meta.js";
let dispatchAcpRuntimePromise: Promise<
typeof import("../auto-reply/reply/dispatch-acp.runtime.js")
> | null = null;
function loadDispatchAcpRuntime() {
dispatchAcpRuntimePromise ??= import("../auto-reply/reply/dispatch-acp.runtime.js");
return dispatchAcpRuntimePromise;
}
function hasExplicitCommandCandidate(ctx: PluginHookReplyDispatchEvent["ctx"]): boolean {
const commandBody = normalizeOptionalString(ctx.CommandBody);
if (commandBody) {
return true;
}
const normalized = normalizeOptionalString(ctx.BodyForCommands);
if (!normalized) {
return false;
}
return normalized.startsWith("!") || normalized.startsWith("/");
}
export async function tryDispatchAcpReplyHook(
event: PluginHookReplyDispatchEvent,
ctx: PluginHookReplyDispatchContext,
): Promise<PluginHookReplyDispatchResult | void> {
// Under sendPolicy: "deny", ACP-bound sessions still need their turns to flow
// through acpManager.runTurn so session state, tool calls, and memory stay
// consistent — only outbound delivery should be suppressed. The ACP delivery
// path (dispatch-acp-delivery.ts) honors event.suppressUserDelivery to drop
// user-facing sends. If suppressUserDelivery is not set under deny, we cannot
// safely route through ACP (delivery would leak), so fall back to the
// embedded reply path unless an explicit command candidate or tail dispatch
// warrants going through ACP anyway.
if (
event.sendPolicy === "deny" &&
!event.suppressUserDelivery &&
!hasExplicitCommandCandidate(event.ctx) &&
!event.isTailDispatch
) {
return;
}
const runtime = await loadDispatchAcpRuntime();
const bypassForCommand = await runtime.shouldBypassAcpDispatchForCommand(event.ctx, ctx.cfg);
if (
event.sendPolicy === "deny" &&
!event.suppressUserDelivery &&
!bypassForCommand &&
!event.isTailDispatch
) {
return;
}
const result = await runtime.tryDispatchAcpReply({
ctx: event.ctx,
cfg: ctx.cfg,
dispatcher: ctx.dispatcher,
runId: event.runId,
sessionKey: event.sessionKey,
images: event.images,
abortSignal: ctx.abortSignal,
inboundAudio: event.inboundAudio,
sessionTtsAuto: event.sessionTtsAuto,
ttsChannel: event.ttsChannel,
suppressUserDelivery: event.suppressUserDelivery,
shouldRouteToOriginating: event.shouldRouteToOriginating,
originatingChannel: event.originatingChannel,
originatingTo: event.originatingTo,
shouldSendToolSummaries: event.shouldSendToolSummaries,
bypassForCommand,
onReplyStart: ctx.onReplyStart,
recordProcessed: ctx.recordProcessed,
markIdle: ctx.markIdle,
});
if (!result) {
return;
}
return {
handled: true,
queuedFinal: result.queuedFinal,
counts: result.counts,
};
}
export { tryDispatchAcpReplyHook } from "./acp-runtime-backend.js";
// Keep test helpers off the hot init path. Eagerly merging them here can
// create a back-edge through the bundled ACP runtime chunk before the imported