mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
* 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)
1515 lines
48 KiB
TypeScript
1515 lines
48 KiB
TypeScript
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";
|
|
import type { ReplyDispatcher } from "./reply-dispatcher.js";
|
|
import { buildTestCtx } from "./test-ctx.js";
|
|
|
|
type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number };
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
|
|
tryFastAbortFromMessage: vi.fn<() => Promise<AbortResult>>(async () => ({
|
|
handled: false,
|
|
aborted: false,
|
|
})),
|
|
}));
|
|
const diagnosticMocks = vi.hoisted(() => ({
|
|
logMessageQueued: vi.fn(),
|
|
logMessageProcessed: vi.fn(),
|
|
logSessionStateChange: vi.fn(),
|
|
}));
|
|
const hookMocks = vi.hoisted(() => ({
|
|
runner: {
|
|
hasHooks: vi.fn(() => false),
|
|
runMessageReceived: vi.fn(async () => {}),
|
|
},
|
|
}));
|
|
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) =>
|
|
Boolean(
|
|
channel &&
|
|
["telegram", "slack", "discord", "signal", "imessage", "whatsapp"].includes(channel),
|
|
),
|
|
routeReply: mocks.routeReply,
|
|
}));
|
|
|
|
vi.mock("./abort.js", () => ({
|
|
tryFastAbortFromMessage: mocks.tryFastAbortFromMessage,
|
|
formatAbortReplyText: (stoppedSubagents?: number) => {
|
|
if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) {
|
|
return "⚙️ Agent was aborted.";
|
|
}
|
|
const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents";
|
|
return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.`;
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../logging/diagnostic.js", () => ({
|
|
logMessageQueued: diagnosticMocks.logMessageQueued,
|
|
logMessageProcessed: diagnosticMocks.logMessageProcessed,
|
|
logSessionStateChange: diagnosticMocks.logSessionStateChange,
|
|
}));
|
|
|
|
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
|
getGlobalHookRunner: () => hookMocks.runner,
|
|
}));
|
|
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;
|
|
type DispatchReplyArgs = Parameters<typeof dispatchReplyFromConfig>[0];
|
|
|
|
function createDispatcher(): ReplyDispatcher {
|
|
return {
|
|
sendToolResult: vi.fn(() => true),
|
|
sendBlockReply: vi.fn(() => true),
|
|
sendFinalReply: vi.fn(() => true),
|
|
waitForIdle: vi.fn(async () => {}),
|
|
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
markComplete: vi.fn(),
|
|
};
|
|
}
|
|
|
|
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
|
|
| undefined;
|
|
}
|
|
|
|
async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs, "dispatcher">) {
|
|
await dispatchReplyFromConfig({
|
|
...params,
|
|
dispatcher: createDispatcher(),
|
|
});
|
|
await dispatchReplyFromConfig({
|
|
...params,
|
|
dispatcher: createDispatcher(),
|
|
});
|
|
}
|
|
|
|
describe("dispatchReplyFromConfig", () => {
|
|
beforeEach(() => {
|
|
acpManagerTesting.resetAcpSessionManagerForTests();
|
|
resetInboundDedupe();
|
|
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
|
|
diagnosticMocks.logMessageQueued.mockClear();
|
|
diagnosticMocks.logMessageProcessed.mockClear();
|
|
diagnosticMocks.logSessionStateChange.mockClear();
|
|
hookMocks.runner.hasHooks.mockClear();
|
|
hookMocks.runner.hasHooks.mockReturnValue(false);
|
|
hookMocks.runner.runMessageReceived.mockClear();
|
|
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();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: undefined,
|
|
OriginatingChannel: "slack",
|
|
OriginatingTo: "channel:C123",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("routes when OriginatingChannel differs from Provider", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
AccountId: "acc-1",
|
|
MessageThreadId: 123,
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
to: "telegram:999",
|
|
accountId: "acc-1",
|
|
threadId: 123,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("routes media-only tool results when summaries are suppressed", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
ChatType: "group",
|
|
AccountId: "acc-1",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
await opts?.onToolResult?.({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["https://example.com/tts-routed.opus"],
|
|
});
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
|
|
const routed = mocks.routeReply.mock.calls[0]?.[0] as { payload?: ReplyPayload } | undefined;
|
|
expect(routed?.payload?.mediaUrls).toEqual(["https://example.com/tts-routed.opus"]);
|
|
expect(routed?.payload?.text).toBeUndefined();
|
|
});
|
|
|
|
it("provides onToolResult in DM sessions", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
expect(typeof opts?.onToolResult).toBe("function");
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses group tool summaries but still forwards tool media", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "group",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
await opts?.onToolResult?.({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["https://example.com/tts-group.opus"],
|
|
});
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
const sent = firstToolResultPayload(dispatcher);
|
|
expect(sent?.mediaUrls).toEqual(["https://example.com/tts-group.opus"]);
|
|
expect(sent?.text).toBeUndefined();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("sends tool results via dispatcher in DM sessions", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
// Simulate tool result emission
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith(
|
|
expect.objectContaining({ text: "🔧 exec: ls" }),
|
|
);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses native tool summaries but still forwards tool media", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
CommandSource: "native",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
expect(opts?.onToolResult).toBeDefined();
|
|
await opts?.onToolResult?.({ text: "🔧 tools/sessions_send" });
|
|
await opts?.onToolResult?.({
|
|
mediaUrl: "https://example.com/tts-native.opus",
|
|
});
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
const sent = firstToolResultPayload(dispatcher);
|
|
expect(sent?.mediaUrl).toBe("https://example.com/tts-native.opus");
|
|
expect(sent?.text).toBeUndefined();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fast-aborts without calling the reply resolver", async () => {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
handled: true,
|
|
aborted: true,
|
|
});
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Body: "/stop",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
|
text: "⚙️ Agent was aborted.",
|
|
});
|
|
});
|
|
|
|
it("fast-abort reply includes stopped subagent count when provided", async () => {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
handled: true,
|
|
aborted: true,
|
|
stoppedSubagents: 2,
|
|
});
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Body: "/stop",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver: vi.fn(async () => ({ text: "hi" }) as ReplyPayload),
|
|
});
|
|
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
|
text: "⚙️ Agent was aborted. Stopped 2 sub-agents.",
|
|
});
|
|
});
|
|
|
|
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;
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550123",
|
|
MessageSid: "msg-1",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
await dispatchTwiceWithFreshDispatchers({
|
|
ctx,
|
|
cfg,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("emits message_received hook with originating channel metadata", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockReturnValue(true);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
OriginatingChannel: "Telegram",
|
|
OriginatingTo: "telegram:999",
|
|
CommandBody: "/search hello",
|
|
RawBody: "raw text",
|
|
Body: "body text",
|
|
Timestamp: 1710000000000,
|
|
MessageSidFull: "sid-full",
|
|
SenderId: "user-1",
|
|
SenderName: "Alice",
|
|
SenderUsername: "alice",
|
|
SenderE164: "+15555550123",
|
|
AccountId: "acc-1",
|
|
GroupSpace: "guild-123",
|
|
GroupChannel: "alerts",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
from: ctx.From,
|
|
content: "/search hello",
|
|
timestamp: 1710000000000,
|
|
metadata: expect.objectContaining({
|
|
originatingChannel: "Telegram",
|
|
originatingTo: "telegram:999",
|
|
messageId: "sid-full",
|
|
senderId: "user-1",
|
|
senderName: "Alice",
|
|
senderUsername: "alice",
|
|
senderE164: "+15555550123",
|
|
guildId: "guild-123",
|
|
channelName: "alerts",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
channelId: "telegram",
|
|
accountId: "acc-1",
|
|
conversationId: "telegram:999",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("emits internal message:received hook when a session key is available", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:main",
|
|
CommandBody: "/help",
|
|
MessageSid: "msg-42",
|
|
GroupSpace: "guild-456",
|
|
GroupChannel: "ops-room",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
|
"message",
|
|
"received",
|
|
"agent:main:main",
|
|
expect.objectContaining({
|
|
from: ctx.From,
|
|
content: "/help",
|
|
channelId: "telegram",
|
|
messageId: "msg-42",
|
|
metadata: expect.objectContaining({
|
|
guildId: "guild-456",
|
|
channelName: "ops-room",
|
|
}),
|
|
}),
|
|
);
|
|
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("skips internal message:received hook when session key is unavailable", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
CommandBody: "/help",
|
|
});
|
|
(ctx as MsgContext).SessionKey = undefined;
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
|
|
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits diagnostics when enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
SessionKey: "agent:main:main",
|
|
MessageSid: "msg-1",
|
|
To: "slack:C123",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main:main",
|
|
state: "processing",
|
|
reason: "message_start",
|
|
});
|
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "slack",
|
|
outcome: "completed",
|
|
sessionKey: "agent:main:main",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("marks diagnostics skipped for duplicate inbound messages", async () => {
|
|
setNoAbort();
|
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550123",
|
|
MessageSid: "msg-dup",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
await dispatchTwiceWithFreshDispatchers({
|
|
ctx,
|
|
cfg,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "whatsapp",
|
|
outcome: "skipped",
|
|
reason: "duplicate",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const replyResolver = async () =>
|
|
[
|
|
{ text: "Reasoning:\n_thinking..._", isReasoning: true },
|
|
{ text: "The answer is 42" },
|
|
] satisfies ReplyPayload[];
|
|
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
|
|
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
|
|
expect(finalCalls).toHaveLength(1);
|
|
expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" });
|
|
});
|
|
|
|
it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const blockReplySentTexts: string[] = [];
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload> => {
|
|
// Simulate block reply with reasoning payload
|
|
await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true });
|
|
await opts?.onBlockReply?.({ text: "The answer is 42" });
|
|
return { text: "The answer is 42" };
|
|
};
|
|
// Capture what actually gets dispatched as block replies
|
|
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
|
|
(payload: ReplyPayload) => {
|
|
if (payload.text) {
|
|
blockReplySentTexts.push(payload.text);
|
|
}
|
|
return true;
|
|
},
|
|
);
|
|
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
|
|
expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._");
|
|
expect(blockReplySentTexts).toContain("The answer is 42");
|
|
});
|
|
});
|