mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 12:03:36 +00:00
Propagate ClickClack account-level runtime tool allowlists through inbound reply dispatch so restricted ClickClack accounts keep their tool policy when model/agent replies are generated. This threads `toolsAllow` through shared dispatch, provider wrappers, embedded agent execution, and ACP hook events. ACP-bound sessions now fail closed for restrictive runtime allowlists because ACPX cannot enforce per-turn tool allowlists on reused persistent sessions. Verification: - Live ClickClack E2E on Crabbox AWS `run_6a0472ed7e71`, provider `aws`, id `cbx_dace25addcaa`. - `node scripts/run-vitest.mjs run src/auto-reply/reply/dispatch-acp.test.ts src/plugin-sdk/acp-runtime.test.ts src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts src/auto-reply/dispatch.test.ts src/auto-reply/reply/agent-runner-execution.test.ts src/auto-reply/reply/provider-dispatcher.test.ts extensions/clickclack/src/inbound.test.ts --reporter=verbose` - Crabbox changed gate `run_d32af37fb265`, provider `aws`, id `cbx_8236876017c9`: `corepack pnpm check:changed` - Autoreview clean: `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` Supersedes #89500. Co-authored-by: Michael Appel <mappel@nvidia.com>
2063 lines
68 KiB
TypeScript
2063 lines
68 KiB
TypeScript
// Tests ACP dispatch wiring, command bypass, and runtime event handling.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
|
import type { AcpSessionStoreEntry } from "../../acp/runtime/session-meta.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
|
import type { MediaUnderstandingSkipError } from "../../media-understanding/errors.js";
|
|
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
|
import {
|
|
resolveAgentAttachments,
|
|
resolveAgentTurnAttachments,
|
|
resolveInlineAgentImageAttachments,
|
|
} from "./agent-turn-attachments.js";
|
|
import { tryDispatchAcpReply } from "./dispatch-acp.js";
|
|
import {
|
|
appendRecentHistoryImageContext,
|
|
resolveRecentInboundHistoryImages,
|
|
} from "./history-media.js";
|
|
import type { ReplyDispatcher } from "./reply-dispatcher.js";
|
|
import { buildTestCtx } from "./test-ctx.js";
|
|
import { createAcpSessionMeta, createAcpTestConfig } from "./test-fixtures/acp-runtime.js";
|
|
|
|
const managerMocks = vi.hoisted(() => ({
|
|
resolveSession: vi.fn(),
|
|
runTurn: vi.fn(),
|
|
getObservabilitySnapshot: vi.fn(() => ({
|
|
turns: { queueDepth: 0 },
|
|
runtimeCache: { activeSessions: 0 },
|
|
})),
|
|
}));
|
|
|
|
const policyMocks = vi.hoisted(() => ({
|
|
resolveAcpDispatchPolicyError: vi.fn<(cfg: OpenClawConfig) => AcpRuntimeError | null>(() => null),
|
|
resolveAcpAgentPolicyError: vi.fn<(cfg: OpenClawConfig, agent: string) => AcpRuntimeError | null>(
|
|
() => null,
|
|
),
|
|
}));
|
|
|
|
const routeMocks = vi.hoisted(() => ({
|
|
routeReply: vi.fn<
|
|
(_params: unknown) => Promise<{ ok: true; messageId: string } | { ok: false; error: string }>
|
|
>(async () => ({ ok: true, messageId: "mock" })),
|
|
}));
|
|
|
|
const channelPluginMocks = vi.hoisted(() => ({
|
|
getChannelPlugin: vi.fn((channelId: string) => {
|
|
if (channelId !== "discord" && channelId !== "slack" && channelId !== "telegram") {
|
|
return undefined;
|
|
}
|
|
return {
|
|
outbound: {
|
|
shouldTreatDeliveredTextAsVisible: ({
|
|
kind,
|
|
text,
|
|
}: {
|
|
kind: "tool" | "block" | "final";
|
|
text?: string;
|
|
}) => kind === "block" && typeof text === "string" && text.trim().length > 0,
|
|
},
|
|
};
|
|
}),
|
|
}));
|
|
|
|
const messageActionMocks = vi.hoisted(() => ({
|
|
runMessageAction: vi.fn(async (_params: unknown) => ({ ok: true as const })),
|
|
}));
|
|
|
|
const ttsMocks = vi.hoisted(() => ({
|
|
maybeApplyTtsToPayload: vi.fn(async (paramsUnknown: unknown) => {
|
|
const params = paramsUnknown as { payload: unknown };
|
|
return params.payload;
|
|
}),
|
|
resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
|
|
}));
|
|
|
|
const mediaUnderstandingMocks = vi.hoisted(() => ({
|
|
applyMediaUnderstanding: vi.fn(async (_params: unknown) => undefined),
|
|
}));
|
|
|
|
const acpAttachmentBuffers = vi.hoisted(() => new Map<string, Buffer>());
|
|
|
|
const diagnosticMocks = vi.hoisted(() => ({
|
|
markDiagnosticSessionProgress: vi.fn(),
|
|
}));
|
|
|
|
const sessionMetaMocks = vi.hoisted(() => ({
|
|
readAcpSessionEntry: vi.fn<
|
|
(params: { sessionKey: string; cfg?: OpenClawConfig }) => AcpSessionStoreEntry | null
|
|
>(() => null),
|
|
}));
|
|
|
|
const transcriptMocks = vi.hoisted(() => ({
|
|
persistAcpDispatchTranscript: vi.fn(async (_params: unknown) => undefined),
|
|
}));
|
|
|
|
const bindingServiceMocks = vi.hoisted(() => ({
|
|
listBySession: vi.fn<(sessionKey: string) => SessionBindingRecord[]>(() => []),
|
|
unbind: vi.fn<(input: unknown) => Promise<SessionBindingRecord[]>>(async () => []),
|
|
}));
|
|
|
|
vi.mock("./dispatch-acp-manager.runtime.js", () => ({
|
|
getAcpSessionManager: () => managerMocks,
|
|
getSessionBindingService: () => ({
|
|
listBySession: (targetSessionKey: string) =>
|
|
bindingServiceMocks.listBySession(targetSessionKey),
|
|
unbind: (input: unknown) => bindingServiceMocks.unbind(input),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../acp/policy.js", () => ({
|
|
resolveAcpDispatchPolicyError: (cfg: OpenClawConfig) =>
|
|
policyMocks.resolveAcpDispatchPolicyError(cfg),
|
|
resolveAcpAgentPolicyError: (cfg: OpenClawConfig, agent: string) =>
|
|
policyMocks.resolveAcpAgentPolicyError(cfg, agent),
|
|
}));
|
|
|
|
vi.mock("./route-reply.runtime.js", () => ({
|
|
routeReply: (params: unknown) => routeMocks.routeReply(params),
|
|
}));
|
|
|
|
vi.mock("../../channels/plugins/index.js", () => ({
|
|
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
|
|
getLoadedChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
|
|
normalizeChannelId: (channelId?: string | null) => channelId?.trim().toLowerCase() || null,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
|
|
runMessageAction: (params: unknown) => messageActionMocks.runMessageAction(params),
|
|
}));
|
|
|
|
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
|
|
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
|
|
}));
|
|
|
|
vi.mock("../../tts/status-config.js", () => ({
|
|
resolveStatusTtsSnapshot: () => ({
|
|
autoMode: "always",
|
|
provider: "auto",
|
|
maxLength: 1500,
|
|
summarize: true,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./dispatch-acp-media.runtime.js", () => ({
|
|
applyMediaUnderstanding: (params: unknown) =>
|
|
mediaUnderstandingMocks.applyMediaUnderstanding(params),
|
|
isMediaUnderstandingSkipError: (error: unknown): error is MediaUnderstandingSkipError =>
|
|
error instanceof Error && error.name === "MediaUnderstandingSkipError",
|
|
normalizeAttachments: (ctx: { MediaPath?: string; MediaType?: string }) =>
|
|
ctx.MediaPath
|
|
? [
|
|
{
|
|
path: ctx.MediaPath,
|
|
mime: ctx.MediaType,
|
|
index: 0,
|
|
},
|
|
]
|
|
: [],
|
|
resolveMediaAttachmentLocalRoots: (params: {
|
|
cfg: { channels?: Record<string, { attachmentRoots?: string[] } | undefined> };
|
|
ctx: { Provider?: string; Surface?: string };
|
|
}) => {
|
|
const channel = params.ctx.Provider ?? params.ctx.Surface ?? "";
|
|
return params.cfg.channels?.[channel]?.attachmentRoots ?? [];
|
|
},
|
|
MediaAttachmentCache: class {
|
|
constructor(private readonly attachments: Array<{ path?: string; index: number }>) {}
|
|
async getBuffer({ attachmentIndex }: { attachmentIndex: number }) {
|
|
const attachment = this.attachments.find((item) => item.index === attachmentIndex);
|
|
const pathLocal = attachment?.path;
|
|
const buffer = pathLocal ? acpAttachmentBuffers.get(pathLocal) : undefined;
|
|
if (buffer) {
|
|
return {
|
|
buffer,
|
|
mime: "image/png",
|
|
fileName: pathLocal,
|
|
size: buffer.length,
|
|
};
|
|
}
|
|
const error = new Error("outside allowed roots");
|
|
error.name = "MediaUnderstandingSkipError";
|
|
throw error;
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock("./dispatch-acp-session.runtime.js", () => ({
|
|
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
|
|
sessionMetaMocks.readAcpSessionEntry(params),
|
|
}));
|
|
|
|
vi.mock("../../logging/diagnostic.js", () => ({
|
|
markDiagnosticSessionProgress: diagnosticMocks.markDiagnosticSessionProgress,
|
|
}));
|
|
|
|
vi.mock("./dispatch-acp-transcript.runtime.js", () => ({
|
|
persistAcpDispatchTranscript: (params: unknown) =>
|
|
transcriptMocks.persistAcpDispatchTranscript(params),
|
|
}));
|
|
|
|
const sessionKey = "agent:codex-acp:session-1";
|
|
const originalFetch = globalThis.fetch;
|
|
type MockTtsReply = Awaited<ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>>;
|
|
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function mockArg(source: MockCallSource, callIndex: number, argIndex: number, _label: string) {
|
|
return source.mock.calls[callIndex]?.[argIndex];
|
|
}
|
|
|
|
function routeCall(index = 0) {
|
|
return requireRecord(
|
|
mockArg(routeMocks.routeReply, index, 0, `route call ${index}`),
|
|
"route call",
|
|
);
|
|
}
|
|
|
|
function routePayload(index = 0) {
|
|
return requireRecord(routeCall(index).payload, `route payload ${index}`);
|
|
}
|
|
|
|
function messageActionCall(index = 0) {
|
|
return requireRecord(
|
|
mockArg(messageActionMocks.runMessageAction, index, 0, `message action ${index}`),
|
|
"message action",
|
|
);
|
|
}
|
|
|
|
function runTurnCall(index = 0) {
|
|
return requireRecord(mockArg(managerMocks.runTurn, index, 0, `run turn ${index}`), "run turn");
|
|
}
|
|
|
|
function dispatcherCall(
|
|
fn:
|
|
| ReplyDispatcher["sendToolResult"]
|
|
| ReplyDispatcher["sendBlockReply"]
|
|
| ReplyDispatcher["sendFinalReply"],
|
|
index = 0,
|
|
) {
|
|
return requireRecord(
|
|
mockArg(fn as unknown as MockCallSource, index, 0, `dispatcher call ${index}`),
|
|
"dispatcher call",
|
|
);
|
|
}
|
|
|
|
function createDispatcher(): {
|
|
dispatcher: ReplyDispatcher;
|
|
counts: Record<"tool" | "block" | "final", number>;
|
|
} {
|
|
const counts = { tool: 0, block: 0, final: 0 };
|
|
const dispatcher: ReplyDispatcher = {
|
|
sendToolResult: vi.fn(() => true),
|
|
sendBlockReply: vi.fn(() => true),
|
|
sendFinalReply: vi.fn(() => true),
|
|
waitForIdle: vi.fn(async () => {}),
|
|
getQueuedCounts: vi.fn(() => counts),
|
|
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
markComplete: vi.fn(),
|
|
};
|
|
return { dispatcher, counts };
|
|
}
|
|
|
|
function setReadyAcpResolution() {
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "ready",
|
|
sessionKey,
|
|
meta: createAcpSessionMeta(),
|
|
});
|
|
}
|
|
|
|
function createAcpConfigWithVisibleToolTags(): OpenClawConfig {
|
|
return createAcpTestConfig({
|
|
acp: {
|
|
enabled: true,
|
|
stream: {
|
|
tagVisibility: {
|
|
tool_call: true,
|
|
tool_call_update: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function runDispatch(params: {
|
|
bodyForAgent: string;
|
|
cfg?: OpenClawConfig;
|
|
dispatcher?: ReplyDispatcher;
|
|
shouldRouteToOriginating?: boolean;
|
|
originatingChannel?: string;
|
|
originatingTo?: string;
|
|
onReplyStart?: () => void;
|
|
images?: Array<{ data: string; mimeType: string }>;
|
|
ctxOverrides?: Record<string, unknown>;
|
|
sessionKeyOverride?: string;
|
|
suppressUserDelivery?: boolean;
|
|
suppressReplyLifecycle?: boolean;
|
|
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
|
|
toolsAllow?: string[];
|
|
}) {
|
|
const targetSessionKey = params.sessionKeyOverride ?? sessionKey;
|
|
return tryDispatchAcpReply({
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
SessionKey: targetSessionKey,
|
|
BodyForAgent: params.bodyForAgent,
|
|
...params.ctxOverrides,
|
|
}),
|
|
cfg: params.cfg ?? createAcpTestConfig(),
|
|
dispatcher: params.dispatcher ?? createDispatcher().dispatcher,
|
|
sessionKey: targetSessionKey,
|
|
images: params.images,
|
|
inboundAudio: false,
|
|
suppressUserDelivery: params.suppressUserDelivery,
|
|
suppressReplyLifecycle: params.suppressReplyLifecycle,
|
|
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
|
shouldRouteToOriginating: params.shouldRouteToOriginating ?? false,
|
|
...(params.shouldRouteToOriginating
|
|
? {
|
|
originatingChannel: params.originatingChannel ?? "telegram",
|
|
originatingTo: params.originatingTo ?? "telegram:thread-1",
|
|
}
|
|
: {}),
|
|
shouldSendToolSummaries: true,
|
|
bypassForCommand: false,
|
|
toolsAllow: params.toolsAllow,
|
|
...(params.onReplyStart ? { onReplyStart: params.onReplyStart } : {}),
|
|
recordProcessed: vi.fn(),
|
|
markIdle: vi.fn(),
|
|
});
|
|
}
|
|
|
|
async function emitToolLifecycleEvents(
|
|
onEvent: (event: unknown) => Promise<void>,
|
|
toolCallId: string,
|
|
) {
|
|
await onEvent({
|
|
type: "tool_call",
|
|
tag: "tool_call",
|
|
toolCallId,
|
|
status: "in_progress",
|
|
title: "Run command",
|
|
text: "Run command (in_progress)",
|
|
});
|
|
await onEvent({
|
|
type: "tool_call",
|
|
tag: "tool_call_update",
|
|
toolCallId,
|
|
status: "completed",
|
|
title: "Run command",
|
|
text: "Run command (completed)",
|
|
});
|
|
await onEvent({ type: "done" });
|
|
}
|
|
|
|
function mockToolLifecycleTurn(toolCallId: string) {
|
|
managerMocks.runTurn.mockImplementation(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await emitToolLifecycleEvents(onEvent, toolCallId);
|
|
},
|
|
);
|
|
}
|
|
|
|
function mockVisibleTextTurn(text = "visible") {
|
|
managerMocks.runTurn.mockImplementationOnce(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
|
|
await onEvent({ type: "done" });
|
|
},
|
|
);
|
|
}
|
|
|
|
function mockRoutedTextTurn(text: string) {
|
|
managerMocks.runTurn.mockImplementation(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await onEvent({ type: "text_delta", text, tag: "agent_message_chunk" });
|
|
await onEvent({ type: "done" });
|
|
},
|
|
);
|
|
}
|
|
|
|
async function dispatchVisibleTurn(onReplyStart: () => void) {
|
|
await runDispatch({
|
|
bodyForAgent: "visible",
|
|
dispatcher: createDispatcher().dispatcher,
|
|
onReplyStart,
|
|
});
|
|
}
|
|
|
|
function queueTtsReplies(...replies: MockTtsReply[]) {
|
|
for (const reply of replies) {
|
|
ttsMocks.maybeApplyTtsToPayload.mockResolvedValueOnce(reply);
|
|
}
|
|
}
|
|
|
|
async function runRoutedAcpTextTurn(text: string) {
|
|
mockRoutedTextTurn(text);
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "run acp",
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
});
|
|
return { result };
|
|
}
|
|
|
|
function expectRoutedPayload(callIndex: number, payload: Partial<MockTtsReply>) {
|
|
const routedPayload = routePayload(callIndex - 1);
|
|
for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
|
|
expect(routedPayload[key]).toEqual(value);
|
|
}
|
|
}
|
|
|
|
describe("tryDispatchAcpReply", () => {
|
|
beforeEach(() => {
|
|
managerMocks.resolveSession.mockReset();
|
|
managerMocks.runTurn.mockReset();
|
|
managerMocks.runTurn.mockImplementation(
|
|
async ({ onEvent }: { onEvent?: (event: unknown) => Promise<void> }) => {
|
|
await onEvent?.({ type: "done" });
|
|
},
|
|
);
|
|
managerMocks.getObservabilitySnapshot.mockReset();
|
|
managerMocks.getObservabilitySnapshot.mockReturnValue({
|
|
turns: { queueDepth: 0 },
|
|
runtimeCache: { activeSessions: 0 },
|
|
});
|
|
policyMocks.resolveAcpDispatchPolicyError.mockReset();
|
|
policyMocks.resolveAcpDispatchPolicyError.mockReturnValue(null);
|
|
policyMocks.resolveAcpAgentPolicyError.mockReset();
|
|
policyMocks.resolveAcpAgentPolicyError.mockReturnValue(null);
|
|
routeMocks.routeReply.mockReset();
|
|
routeMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
|
channelPluginMocks.getChannelPlugin.mockClear();
|
|
messageActionMocks.runMessageAction.mockReset();
|
|
messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const });
|
|
ttsMocks.maybeApplyTtsToPayload.mockReset();
|
|
ttsMocks.maybeApplyTtsToPayload.mockImplementation(async (paramsUnknown: unknown) => {
|
|
const params = paramsUnknown as { payload: unknown };
|
|
return params.payload;
|
|
});
|
|
ttsMocks.resolveTtsConfig.mockReset();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
mediaUnderstandingMocks.applyMediaUnderstanding.mockReset();
|
|
mediaUnderstandingMocks.applyMediaUnderstanding.mockResolvedValue(undefined);
|
|
acpAttachmentBuffers.clear();
|
|
diagnosticMocks.markDiagnosticSessionProgress.mockReset();
|
|
sessionMetaMocks.readAcpSessionEntry.mockReset();
|
|
sessionMetaMocks.readAcpSessionEntry.mockReturnValue(null);
|
|
transcriptMocks.persistAcpDispatchTranscript.mockClear();
|
|
bindingServiceMocks.listBySession.mockReset();
|
|
bindingServiceMocks.listBySession.mockReturnValue([]);
|
|
bindingServiceMocks.unbind.mockReset();
|
|
bindingServiceMocks.unbind.mockResolvedValue([]);
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("routes default ACP output to the originating channel as a final reply", async () => {
|
|
setReadyAcpResolution();
|
|
mockRoutedTextTurn("hello");
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(1);
|
|
expect(routeCall().channel).toBe("telegram");
|
|
expect(routeCall().to).toBe("telegram:thread-1");
|
|
expect(routePayload().text).toBe("hello");
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists ACP transcript when routed delivery fails", async () => {
|
|
setReadyAcpResolution();
|
|
mockRoutedTextTurn("hello");
|
|
routeMocks.routeReply.mockResolvedValue({ ok: false, error: "missing channel adapter" });
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "reply",
|
|
shouldRouteToOriginating: true,
|
|
});
|
|
|
|
const transcript = requireRecord(
|
|
mockArg(transcriptMocks.persistAcpDispatchTranscript, 0, 0, "transcript call"),
|
|
"transcript call",
|
|
);
|
|
expect(transcript.sessionKey).toBe(sessionKey);
|
|
expect(transcript.promptText).toBe("reply");
|
|
expect(transcript.finalText).toBe("hello");
|
|
expect(routeCall().mirror).toBe(false);
|
|
});
|
|
|
|
it("adds source delivery guidance to tool-only ACP turns", async () => {
|
|
setReadyAcpResolution();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "reply privately unless you send explicitly",
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
|
|
expect(managerMocks.runTurn).toHaveBeenCalledTimes(1);
|
|
const text = runTurnCall().text;
|
|
expect(text).toContain("Source channel delivery is private by default");
|
|
expect(text).toContain("message(action=send)");
|
|
expect(text).toContain("The target defaults to the current source channel");
|
|
expect(text).toContain("reply privately unless you send explicitly");
|
|
});
|
|
|
|
it("starts reply lifecycle for tool-only ACP turns while suppressing automatic delivery", async () => {
|
|
setReadyAcpResolution();
|
|
mockVisibleTextTurn("hidden final");
|
|
const onReplyStart = vi.fn();
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply via message tool if needed",
|
|
dispatcher,
|
|
onReplyStart,
|
|
suppressUserDelivery: true,
|
|
suppressReplyLifecycle: false,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
|
|
expect(result?.queuedFinal).toBe(false);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps same-provider tool-only ACP final replies private when an origin route exists", async () => {
|
|
setReadyAcpResolution();
|
|
mockVisibleTextTurn("hidden final");
|
|
const onReplyStart = vi.fn();
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply via message tool if needed",
|
|
dispatcher,
|
|
onReplyStart,
|
|
suppressUserDelivery: true,
|
|
suppressReplyLifecycle: false,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
shouldRouteToOriginating: true,
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:C1",
|
|
});
|
|
|
|
expect(result?.queuedFinal).toBe(false);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
expect(routeMocks.routeReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("edits ACP tool lifecycle updates in place when supported", async () => {
|
|
setReadyAcpResolution();
|
|
mockToolLifecycleTurn("call-1");
|
|
routeMocks.routeReply.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-1" });
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
await runDispatch({
|
|
bodyForAgent: "run tool",
|
|
cfg: createAcpConfigWithVisibleToolTags(),
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
});
|
|
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
|
expect(messageActionCall().action).toBe("edit");
|
|
expect(requireRecord(messageActionCall().params, "message action params").messageId).toBe(
|
|
"tool-msg-1",
|
|
);
|
|
});
|
|
|
|
it("falls back to new tool message when edit fails", async () => {
|
|
setReadyAcpResolution();
|
|
mockToolLifecycleTurn("call-2");
|
|
routeMocks.routeReply
|
|
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2" })
|
|
.mockResolvedValueOnce({ ok: true, messageId: "tool-msg-2-fallback" });
|
|
messageActionMocks.runMessageAction.mockRejectedValueOnce(new Error("edit unsupported"));
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
await runDispatch({
|
|
bodyForAgent: "run tool",
|
|
cfg: createAcpConfigWithVisibleToolTags(),
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
});
|
|
|
|
expect(messageActionMocks.runMessageAction).toHaveBeenCalledTimes(1);
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("starts reply lifecycle when ACP turn starts, including hidden-only turns", async () => {
|
|
setReadyAcpResolution();
|
|
const onReplyStart = vi.fn();
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
managerMocks.runTurn.mockImplementationOnce(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await onEvent({
|
|
type: "status",
|
|
tag: "usage_update",
|
|
text: "usage updated: 1/100",
|
|
used: 1,
|
|
size: 100,
|
|
});
|
|
await onEvent({ type: "done" });
|
|
},
|
|
);
|
|
await runDispatch({
|
|
bodyForAgent: "hidden",
|
|
dispatcher,
|
|
onReplyStart,
|
|
});
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
|
|
mockVisibleTextTurn();
|
|
await dispatchVisibleTurn(onReplyStart);
|
|
expect(onReplyStart).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("starts reply lifecycle once per turn when output is delivered", async () => {
|
|
setReadyAcpResolution();
|
|
const onReplyStart = vi.fn();
|
|
|
|
mockVisibleTextTurn();
|
|
await dispatchVisibleTurn(onReplyStart);
|
|
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not mark ACP diagnostic progress when diagnostics are disabled", async () => {
|
|
setReadyAcpResolution();
|
|
mockVisibleTextTurn();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "visible",
|
|
cfg: createAcpTestConfig({ diagnostics: { enabled: false } }),
|
|
});
|
|
|
|
expect(diagnosticMocks.markDiagnosticSessionProgress).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not start reply lifecycle for empty ACP prompt", async () => {
|
|
setReadyAcpResolution();
|
|
const onReplyStart = vi.fn();
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: " ",
|
|
dispatcher,
|
|
onReplyStart,
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
expect(onReplyStart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips media understanding for text-only ACP turns", async () => {
|
|
setReadyAcpResolution();
|
|
mockVisibleTextTurn("text only");
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "plain text prompt",
|
|
});
|
|
|
|
expect(mediaUnderstandingMocks.applyMediaUnderstanding).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes the ACP agent directory to media understanding", async () => {
|
|
setReadyAcpResolution();
|
|
mockVisibleTextTurn("image turn");
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const agentDir = path.join(tempDir, "codex-agent");
|
|
const imagePath = path.join(tempDir, "inbound.png");
|
|
try {
|
|
await fs.mkdir(agentDir);
|
|
await fs.writeFile(imagePath, "image-bytes");
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "describe image",
|
|
cfg: createAcpTestConfig({
|
|
agents: {
|
|
list: [{ id: "codex-acp", agentDir }],
|
|
},
|
|
channels: {
|
|
imessage: {
|
|
attachmentRoots: [tempDir],
|
|
},
|
|
},
|
|
}),
|
|
ctxOverrides: {
|
|
Provider: "imessage",
|
|
Surface: "imessage",
|
|
MediaPath: imagePath,
|
|
MediaType: "image/png",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
requireRecord(
|
|
mockArg(mediaUnderstandingMocks.applyMediaUnderstanding, 0, 0, "media understanding"),
|
|
"media understanding",
|
|
).agentDir,
|
|
).toBe(agentDir);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("forwards normalized image attachments into agent runtime turns", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const imagePath = path.join(tempDir, "inbound.png");
|
|
try {
|
|
await fs.writeFile(imagePath, "image-bytes");
|
|
const attachments = await resolveAgentAttachments({
|
|
cfg: createAcpTestConfig({
|
|
channels: {
|
|
imessage: {
|
|
attachmentRoots: [tempDir],
|
|
},
|
|
},
|
|
}),
|
|
ctx: buildTestCtx({
|
|
Provider: "imessage",
|
|
Surface: "imessage",
|
|
MediaPath: imagePath,
|
|
MediaType: "image/png",
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
async getBuffer() {
|
|
return {
|
|
buffer: Buffer.from("image-bytes"),
|
|
mime: "image/png",
|
|
fileName: "inbound.png",
|
|
size: "image-bytes".length,
|
|
};
|
|
}
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments: (ctx) => [
|
|
{
|
|
path: ctx.MediaPath,
|
|
mime: ctx.MediaType,
|
|
index: 0,
|
|
},
|
|
],
|
|
resolveMediaAttachmentLocalRoots: () => [tempDir],
|
|
},
|
|
});
|
|
|
|
expect(attachments).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: Buffer.from("image-bytes").toString("base64"),
|
|
},
|
|
]);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("selects bounded recent local history images", () => {
|
|
const now = 1_700_000_000_000;
|
|
const ctx = buildTestCtx({
|
|
Timestamp: now,
|
|
InboundHistory: [
|
|
{
|
|
sender: "Old",
|
|
body: "<media:image>",
|
|
timestamp: now - 31 * 60_000,
|
|
messageId: "old",
|
|
media: [{ path: "/tmp/old.png", contentType: "image/png", kind: "image" }],
|
|
},
|
|
{
|
|
sender: "Doc",
|
|
body: "<media:document>",
|
|
timestamp: now - 1_000,
|
|
messageId: "doc",
|
|
media: [{ path: "/tmp/doc.pdf", contentType: "application/pdf", kind: "document" }],
|
|
},
|
|
{
|
|
sender: "Remote",
|
|
body: "<media:image>",
|
|
timestamp: now - 1_000,
|
|
messageId: "remote",
|
|
media: [
|
|
{ path: "https://example.com/image.png", contentType: "image/png", kind: "image" },
|
|
],
|
|
},
|
|
...Array.from({ length: 5 }, (_, index) => ({
|
|
sender: `Recent ${index}`,
|
|
body: "<media:image>",
|
|
timestamp: now - (5 - index) * 1_000,
|
|
messageId: `recent-${index}`,
|
|
media: [
|
|
{ path: `/tmp/recent-${index}.png`, contentType: "image/png", kind: "image" as const },
|
|
],
|
|
})),
|
|
{
|
|
sender: "Windows",
|
|
body: "<media:image>",
|
|
timestamp: now - 500,
|
|
messageId: "windows",
|
|
media: [
|
|
{
|
|
path: "C:\\Users\\Alice\\Pictures\\recent.png",
|
|
contentType: "image/png",
|
|
kind: "image",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(resolveRecentInboundHistoryImages({ ctx })).toEqual([
|
|
{
|
|
path: "/tmp/recent-2.png",
|
|
contentType: "image/png",
|
|
sender: "Recent 2",
|
|
messageId: "recent-2",
|
|
},
|
|
{
|
|
path: "/tmp/recent-3.png",
|
|
contentType: "image/png",
|
|
sender: "Recent 3",
|
|
messageId: "recent-3",
|
|
},
|
|
{
|
|
path: "/tmp/recent-4.png",
|
|
contentType: "image/png",
|
|
sender: "Recent 4",
|
|
messageId: "recent-4",
|
|
},
|
|
{
|
|
path: "C:\\Users\\Alice\\Pictures\\recent.png",
|
|
contentType: "image/png",
|
|
sender: "Windows",
|
|
messageId: "windows",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("adds recent history image context without exposing paths", () => {
|
|
const text = appendRecentHistoryImageContext({
|
|
promptText: "what is this?",
|
|
images: [
|
|
{
|
|
path: "/tmp/secret.png",
|
|
contentType: "image/png",
|
|
sender: "@alice",
|
|
messageId: "msg-1",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(text).toContain("what is this?");
|
|
expect(text).toContain("Recent image 1 from @alice, message msg-1");
|
|
expect(text).not.toContain("/tmp/secret.png");
|
|
});
|
|
|
|
it("forwards recent history image attachments into agent runtime turns", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-history-"));
|
|
const imagePath = path.join(tempDir, "recent.png");
|
|
try {
|
|
await fs.writeFile(imagePath, "recent-image");
|
|
const result = await resolveAgentTurnAttachments({
|
|
cfg: createAcpTestConfig(),
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
Timestamp: 1_700_000_000_000,
|
|
InboundHistory: [
|
|
{
|
|
sender: "@alice",
|
|
body: "<media:image>",
|
|
timestamp: 1_700_000_000_000,
|
|
messageId: "msg-1",
|
|
media: [{ path: imagePath, contentType: "image/png", kind: "image" }],
|
|
},
|
|
],
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
constructor(private readonly attachments: Array<{ path?: string; index: number }>) {}
|
|
async getBuffer({ attachmentIndex }: { attachmentIndex: number }) {
|
|
const attachment = this.attachments.find((item) => item.index === attachmentIndex);
|
|
return {
|
|
buffer: Buffer.from(attachment?.path ?? ""),
|
|
mime: "image/png",
|
|
fileName: "recent.png",
|
|
size: attachment?.path?.length ?? 0,
|
|
};
|
|
}
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments: () => [],
|
|
resolveMediaAttachmentLocalRoots: () => [tempDir],
|
|
},
|
|
});
|
|
|
|
expect(result.attachments).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: Buffer.from(imagePath).toString("base64"),
|
|
},
|
|
]);
|
|
expect(result.recentHistoryImages).toEqual([
|
|
{
|
|
path: imagePath,
|
|
contentType: "image/png",
|
|
sender: "@alice",
|
|
messageId: "msg-1",
|
|
},
|
|
]);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps text-only turns off the agent media runtime", async () => {
|
|
const normalizeAttachments = vi.fn(() => {
|
|
throw new Error("media runtime should not be touched");
|
|
});
|
|
|
|
const result = await resolveAgentTurnAttachments({
|
|
cfg: createAcpTestConfig(),
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
BodyForAgent: "hello",
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
readonly __mock = true;
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments,
|
|
resolveMediaAttachmentLocalRoots: () => [],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ attachments: [], recentHistoryImages: [] });
|
|
expect(normalizeAttachments).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not inject recent history images when the current turn already has an image", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-current-"));
|
|
const currentPath = path.join(tempDir, "current.png");
|
|
const historyPath = path.join(tempDir, "history.png");
|
|
try {
|
|
await fs.writeFile(currentPath, "current-image");
|
|
await fs.writeFile(historyPath, "history-image");
|
|
const result = await resolveAgentTurnAttachments({
|
|
cfg: createAcpTestConfig(),
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
MediaPath: currentPath,
|
|
MediaType: "image/png",
|
|
Timestamp: 1_700_000_000_000,
|
|
InboundHistory: [
|
|
{
|
|
sender: "@alice",
|
|
body: "<media:image>",
|
|
timestamp: 1_700_000_000_000,
|
|
media: [{ path: historyPath, contentType: "image/png", kind: "image" }],
|
|
},
|
|
],
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
async getBuffer() {
|
|
return {
|
|
buffer: Buffer.from("current-image"),
|
|
mime: "image/png",
|
|
fileName: "current.png",
|
|
size: "current-image".length,
|
|
};
|
|
}
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments: (ctx) => [{ path: ctx.MediaPath, mime: ctx.MediaType, index: 0 }],
|
|
resolveMediaAttachmentLocalRoots: () => [tempDir],
|
|
},
|
|
});
|
|
|
|
expect(result.attachments).toHaveLength(1);
|
|
expect(result.recentHistoryImages).toEqual([]);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps history attachment indexes distinct from sparse current media indexes", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-sparse-history-"));
|
|
const currentPath = path.join(tempDir, "current.png");
|
|
const historyPath = path.join(tempDir, "history.png");
|
|
const seenAttachmentIndexes: number[] = [];
|
|
try {
|
|
await fs.writeFile(currentPath, "current-image");
|
|
await fs.writeFile(historyPath, "history-image");
|
|
const result = await resolveAgentTurnAttachments({
|
|
cfg: createAcpTestConfig(),
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
MediaPath: currentPath,
|
|
MediaType: "image/png",
|
|
Timestamp: 1_700_000_000_000,
|
|
InboundHistory: [
|
|
{
|
|
sender: "@alice",
|
|
body: "<media:image>",
|
|
timestamp: 1_700_000_000_000,
|
|
messageId: "msg-history",
|
|
media: [{ path: historyPath, contentType: "image/png", kind: "image" }],
|
|
},
|
|
],
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
constructor(private readonly attachments: Array<{ path?: string; index: number }>) {}
|
|
async getBuffer({ attachmentIndex }: { attachmentIndex: number }) {
|
|
seenAttachmentIndexes.push(attachmentIndex);
|
|
const attachment = this.attachments.find((item) => item.index === attachmentIndex);
|
|
return {
|
|
buffer: Buffer.from(attachment?.path ?? ""),
|
|
mime: "image/png",
|
|
fileName: "current.png",
|
|
size: attachment?.path?.length ?? 0,
|
|
};
|
|
}
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments: (ctx) => [{ path: ctx.MediaPath, mime: ctx.MediaType, index: 1 }],
|
|
resolveMediaAttachmentLocalRoots: () => [tempDir],
|
|
},
|
|
});
|
|
|
|
expect(result.attachments).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: Buffer.from(currentPath).toString("base64"),
|
|
},
|
|
]);
|
|
expect(result.recentHistoryImages).toEqual([]);
|
|
expect(seenAttachmentIndexes).toEqual([1]);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not fall back to recent history images when the current turn has non-image media", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-current-pdf-"));
|
|
const documentPath = path.join(tempDir, "current.pdf");
|
|
const historyPath = path.join(tempDir, "history.png");
|
|
const getBuffer = vi.fn();
|
|
try {
|
|
await fs.writeFile(documentPath, "current-pdf");
|
|
await fs.writeFile(historyPath, "history-image");
|
|
const result = await resolveAgentTurnAttachments({
|
|
cfg: createAcpTestConfig(),
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
MediaPath: documentPath,
|
|
MediaType: "application/pdf",
|
|
Timestamp: 1_700_000_000_000,
|
|
InboundHistory: [
|
|
{
|
|
sender: "@alice",
|
|
body: "<media:image>",
|
|
timestamp: 1_700_000_000_000,
|
|
messageId: "msg-history",
|
|
media: [{ path: historyPath, contentType: "image/png", kind: "image" }],
|
|
},
|
|
],
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
async getBuffer(params: { attachmentIndex: number }) {
|
|
return getBuffer(params);
|
|
}
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments: (ctx) => [{ path: ctx.MediaPath, mime: ctx.MediaType, index: 0 }],
|
|
resolveMediaAttachmentLocalRoots: () => [tempDir],
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ attachments: [], recentHistoryImages: [] });
|
|
expect(getBuffer).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("falls back to recent history images when current image attachments are unusable", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-history-fallback-"));
|
|
const historyPath = path.join(tempDir, "history.png");
|
|
try {
|
|
await fs.writeFile(historyPath, "history-image");
|
|
const result = await resolveAgentTurnAttachments({
|
|
cfg: createAcpTestConfig(),
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
MediaUrl: "https://example.com/current.png",
|
|
MediaType: "image/png",
|
|
Timestamp: 1_700_000_000_000,
|
|
InboundHistory: [
|
|
{
|
|
sender: "@alice",
|
|
body: "<media:image>",
|
|
timestamp: 1_700_000_000_000,
|
|
messageId: "msg-history",
|
|
media: [{ path: historyPath, contentType: "image/png", kind: "image" }],
|
|
},
|
|
],
|
|
}),
|
|
runtime: {
|
|
MediaAttachmentCache: class {
|
|
constructor(private readonly attachments: Array<{ path?: string; index: number }>) {}
|
|
async getBuffer({ attachmentIndex }: { attachmentIndex: number }) {
|
|
const attachment = this.attachments.find((item) => item.index === attachmentIndex);
|
|
return {
|
|
buffer: Buffer.from(attachment?.path ?? ""),
|
|
mime: "image/png",
|
|
fileName: "history.png",
|
|
size: attachment?.path?.length ?? 0,
|
|
};
|
|
}
|
|
} as unknown as typeof import("./dispatch-acp-media.runtime.js").MediaAttachmentCache,
|
|
isMediaUnderstandingSkipError: (_error: unknown): _error is MediaUnderstandingSkipError =>
|
|
false,
|
|
normalizeAttachments: (ctx) => [{ url: ctx.MediaUrl, mime: ctx.MediaType, index: 0 }],
|
|
resolveMediaAttachmentLocalRoots: () => [tempDir],
|
|
},
|
|
});
|
|
|
|
expect(result.attachments).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: Buffer.from(historyPath).toString("base64"),
|
|
},
|
|
]);
|
|
expect(result.recentHistoryImages).toEqual([
|
|
{
|
|
path: historyPath,
|
|
contentType: "image/png",
|
|
sender: "@alice",
|
|
messageId: "msg-history",
|
|
},
|
|
]);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("forwards chat.send inline image attachments into agent runtime turns", async () => {
|
|
setReadyAcpResolution();
|
|
const image = {
|
|
mimeType: "image/png",
|
|
data: Buffer.from("image-bytes").toString("base64"),
|
|
};
|
|
|
|
expect(resolveInlineAgentImageAttachments([image])).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: image.data,
|
|
},
|
|
]);
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "describe image",
|
|
images: [image],
|
|
});
|
|
|
|
expect(runTurnCall().text).toBe("describe image");
|
|
expect(runTurnCall().attachments).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: image.data,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("preserves chat.send inline image attachments over recent history images", async () => {
|
|
setReadyAcpResolution();
|
|
const image = {
|
|
mimeType: "image/png",
|
|
data: Buffer.from("inline-image").toString("base64"),
|
|
};
|
|
const historyPath = "/tmp/openclaw-history-inline.png";
|
|
acpAttachmentBuffers.set(historyPath, Buffer.from("history-image"));
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "describe image",
|
|
images: [image],
|
|
ctxOverrides: {
|
|
Timestamp: 1_700_000_000_000,
|
|
InboundHistory: [
|
|
{
|
|
sender: "@alice",
|
|
body: "<media:image>",
|
|
timestamp: 1_700_000_000_000,
|
|
messageId: "msg-history",
|
|
media: [{ path: historyPath, contentType: "image/png", kind: "image" }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(runTurnCall().text).toBe("describe image");
|
|
expect(runTurnCall().attachments).toEqual([
|
|
{
|
|
mediaType: "image/png",
|
|
data: image.data,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("skips agent runtime attachments outside allowed inbound roots", async () => {
|
|
setReadyAcpResolution();
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const imagePath = path.join(tempDir, "outside-root.png");
|
|
try {
|
|
await fs.writeFile(imagePath, "image-bytes");
|
|
managerMocks.runTurn.mockResolvedValue(undefined);
|
|
|
|
await runDispatch({
|
|
bodyForAgent: " ",
|
|
ctxOverrides: {
|
|
MediaPath: imagePath,
|
|
MediaType: "image/png",
|
|
},
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips file URL agent runtime attachments outside allowed inbound roots", async () => {
|
|
setReadyAcpResolution();
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const imagePath = path.join(tempDir, "outside-root.png");
|
|
try {
|
|
await fs.writeFile(imagePath, "image-bytes");
|
|
managerMocks.runTurn.mockResolvedValue(undefined);
|
|
|
|
await runDispatch({
|
|
bodyForAgent: " ",
|
|
ctxOverrides: {
|
|
MediaPath: `file://${imagePath}`,
|
|
MediaType: "image/png",
|
|
},
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips relative ACP attachment paths that resolve outside allowed inbound roots", async () => {
|
|
setReadyAcpResolution();
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const imagePath = path.join(tempDir, "outside-root.png");
|
|
try {
|
|
await fs.writeFile(imagePath, "image-bytes");
|
|
managerMocks.runTurn.mockResolvedValue(undefined);
|
|
|
|
await runDispatch({
|
|
bodyForAgent: " ",
|
|
ctxOverrides: {
|
|
MediaPath: path.relative(process.cwd(), imagePath),
|
|
MediaType: "image/png",
|
|
},
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not fall back to remote URLs when ACP local attachment paths are blocked", async () => {
|
|
setReadyAcpResolution();
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const imagePath = path.join(tempDir, "outside-root.png");
|
|
const fetchSpy = vi.fn(
|
|
async () =>
|
|
new Response(Buffer.from("remote-image"), {
|
|
headers: {
|
|
"content-type": "image/png",
|
|
},
|
|
}),
|
|
);
|
|
globalThis.fetch = withFetchPreconnect(fetchSpy as typeof fetch);
|
|
try {
|
|
await fs.writeFile(imagePath, "image-bytes");
|
|
managerMocks.runTurn.mockResolvedValue(undefined);
|
|
|
|
await runDispatch({
|
|
bodyForAgent: " ",
|
|
ctxOverrides: {
|
|
MediaPath: imagePath,
|
|
MediaUrl: "https://example.com/image.png",
|
|
MediaType: "image/png",
|
|
},
|
|
});
|
|
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips ACP turns for non-image attachments when there is no text prompt", async () => {
|
|
setReadyAcpResolution();
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "dispatch-acp-"));
|
|
const docPath = path.join(tempDir, "inbound.pdf");
|
|
const { dispatcher } = createDispatcher();
|
|
const onReplyStart = vi.fn();
|
|
try {
|
|
await fs.writeFile(docPath, "pdf-bytes");
|
|
|
|
await runDispatch({
|
|
bodyForAgent: " ",
|
|
dispatcher,
|
|
onReplyStart,
|
|
ctxOverrides: {
|
|
MediaPath: docPath,
|
|
MediaType: "application/pdf",
|
|
},
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
expect(onReplyStart).not.toHaveBeenCalled();
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("surfaces ACP policy errors as final error replies", async () => {
|
|
setReadyAcpResolution();
|
|
policyMocks.resolveAcpDispatchPolicyError.mockReturnValue(
|
|
new AcpRuntimeError("ACP_DISPATCH_DISABLED", "ACP dispatch is disabled by policy."),
|
|
);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain(
|
|
"ACP dispatch is disabled by policy.",
|
|
);
|
|
expect(bindingServiceMocks.unbind).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when ACP dispatch cannot enforce restrictive runtime toolsAllow", async () => {
|
|
setReadyAcpResolution();
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
toolsAllow: ["message"],
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain("runtime toolsAllow");
|
|
});
|
|
|
|
it("allows wildcard runtime toolsAllow through ACP dispatch", async () => {
|
|
setReadyAcpResolution();
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
toolsAllow: ["*"],
|
|
});
|
|
|
|
expect(managerMocks.runTurn).toHaveBeenCalledOnce();
|
|
expect(runTurnCall().text).toBe("test");
|
|
});
|
|
|
|
it("does not unbind stale bindings when ACP dispatch is disabled by policy", async () => {
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "stale",
|
|
sessionKey,
|
|
error: new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP metadata is missing."),
|
|
});
|
|
policyMocks.resolveAcpDispatchPolicyError.mockReturnValue(
|
|
new AcpRuntimeError("ACP_DISPATCH_DISABLED", "ACP dispatch is disabled by policy."),
|
|
);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
expect(bindingServiceMocks.unbind).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain(
|
|
"ACP dispatch is disabled by policy.",
|
|
);
|
|
});
|
|
|
|
it("unbinds stale bound conversations before surfacing stale ACP resolution errors", async () => {
|
|
const aliasSessionKey = "main";
|
|
const canonicalSessionKey = "agent:main:main";
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "stale",
|
|
sessionKey: canonicalSessionKey,
|
|
error: new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP metadata is missing."),
|
|
});
|
|
bindingServiceMocks.unbind.mockResolvedValueOnce([
|
|
{
|
|
bindingId: "discord:default:thread-1",
|
|
targetSessionKey: canonicalSessionKey,
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "thread-1",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
},
|
|
]);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
sessionKeyOverride: aliasSessionKey,
|
|
});
|
|
|
|
expect(managerMocks.runTurn).not.toHaveBeenCalled();
|
|
expect(bindingServiceMocks.unbind).toHaveBeenCalledTimes(1);
|
|
expect(bindingServiceMocks.unbind).toHaveBeenCalledWith({
|
|
targetSessionKey: canonicalSessionKey,
|
|
reason: "acp-session-init-failed",
|
|
});
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain("ACP metadata is missing.");
|
|
});
|
|
|
|
it("does not unbind valid bindings on generic ACP runTurn init failure", async () => {
|
|
setReadyAcpResolution();
|
|
// Match the post-reset module instance so dispatch-acp preserves the ACP error code.
|
|
managerMocks.runTurn.mockRejectedValueOnce(
|
|
new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Could not initialize ACP session runtime."),
|
|
);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
});
|
|
|
|
expect(bindingServiceMocks.unbind).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain(
|
|
"Could not initialize ACP session runtime.",
|
|
);
|
|
});
|
|
|
|
it("unbinds stale bindings on ACP runTurn missing-metadata failures", async () => {
|
|
const aliasSessionKey = "main";
|
|
const canonicalSessionKey = "agent:main:main";
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "ready",
|
|
sessionKey: canonicalSessionKey,
|
|
meta: createAcpSessionMeta(),
|
|
});
|
|
managerMocks.runTurn.mockRejectedValueOnce(
|
|
new AcpRuntimeError(
|
|
"ACP_SESSION_INIT_FAILED",
|
|
`ACP metadata is missing for ${canonicalSessionKey}. Recreate this ACP session with /acp spawn and rebind the thread.`,
|
|
),
|
|
);
|
|
bindingServiceMocks.unbind.mockResolvedValueOnce([
|
|
{
|
|
bindingId: "discord:default:thread-1",
|
|
targetSessionKey: canonicalSessionKey,
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "thread-1",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
},
|
|
]);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
sessionKeyOverride: aliasSessionKey,
|
|
});
|
|
|
|
expect(bindingServiceMocks.unbind).toHaveBeenCalledTimes(1);
|
|
expect(bindingServiceMocks.unbind).toHaveBeenCalledWith({
|
|
targetSessionKey: canonicalSessionKey,
|
|
reason: "acp-session-init-failed",
|
|
});
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain("ACP metadata is missing");
|
|
});
|
|
|
|
it("uses canonical session keys for bound-session identity notices", async () => {
|
|
const aliasSessionKey = "main";
|
|
const canonicalSessionKey = "agent:main:main";
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "ready",
|
|
sessionKey: canonicalSessionKey,
|
|
meta: createAcpSessionMeta({
|
|
identity: {
|
|
state: "pending",
|
|
source: "ensure",
|
|
lastUpdatedAt: Date.now(),
|
|
acpxRecordId: "rec-main",
|
|
},
|
|
}),
|
|
});
|
|
bindingServiceMocks.listBySession.mockImplementation((targetSessionKey: string) =>
|
|
targetSessionKey === canonicalSessionKey
|
|
? [
|
|
{
|
|
bindingId: "discord:default:thread-1",
|
|
targetSessionKey: canonicalSessionKey,
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "thread-1",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
sessionMetaMocks.readAcpSessionEntry.mockImplementation(
|
|
(params: { sessionKey: string; cfg?: OpenClawConfig }) =>
|
|
params.sessionKey === canonicalSessionKey
|
|
? {
|
|
cfg: params.cfg ?? createAcpTestConfig(),
|
|
storePath: "/tmp/openclaw-session-store.json",
|
|
sessionKey: canonicalSessionKey,
|
|
storeSessionKey: canonicalSessionKey,
|
|
acp: createAcpSessionMeta({
|
|
identity: {
|
|
state: "resolved",
|
|
source: "status",
|
|
lastUpdatedAt: Date.now(),
|
|
acpxSessionId: "acpx-main",
|
|
},
|
|
}),
|
|
}
|
|
: null,
|
|
);
|
|
managerMocks.runTurn.mockResolvedValue(undefined);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
sessionKeyOverride: aliasSessionKey,
|
|
});
|
|
|
|
expect(bindingServiceMocks.listBySession).toHaveBeenCalledWith(canonicalSessionKey);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply, 0).text).toContain("Session ids resolved.");
|
|
expect(dispatcherCall(dispatcher.sendFinalReply, 0).text).toContain(
|
|
"acpx session id: acpx-main",
|
|
);
|
|
});
|
|
|
|
it("honors the configured default account when checking bound-session identity notices", async () => {
|
|
const canonicalSessionKey = "agent:main:main";
|
|
managerMocks.resolveSession.mockReturnValue({
|
|
kind: "ready",
|
|
sessionKey: canonicalSessionKey,
|
|
meta: createAcpSessionMeta({
|
|
identity: {
|
|
state: "pending",
|
|
source: "ensure",
|
|
lastUpdatedAt: Date.now(),
|
|
acpxRecordId: "rec-work",
|
|
},
|
|
}),
|
|
});
|
|
bindingServiceMocks.listBySession.mockImplementation((targetSessionKey: string) =>
|
|
targetSessionKey === canonicalSessionKey
|
|
? [
|
|
{
|
|
bindingId: "discord:work:thread-1",
|
|
targetSessionKey: canonicalSessionKey,
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "work",
|
|
conversationId: "thread-1",
|
|
},
|
|
status: "active",
|
|
boundAt: 0,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
sessionMetaMocks.readAcpSessionEntry.mockImplementation(
|
|
(params: { sessionKey: string; cfg?: OpenClawConfig }) =>
|
|
params.sessionKey === canonicalSessionKey
|
|
? {
|
|
cfg: params.cfg ?? createAcpTestConfig(),
|
|
storePath: "/tmp/openclaw-session-store.json",
|
|
sessionKey: canonicalSessionKey,
|
|
storeSessionKey: canonicalSessionKey,
|
|
acp: createAcpSessionMeta({
|
|
identity: {
|
|
state: "resolved",
|
|
source: "status",
|
|
lastUpdatedAt: Date.now(),
|
|
acpxSessionId: "acpx-work",
|
|
},
|
|
}),
|
|
}
|
|
: null,
|
|
);
|
|
managerMocks.runTurn.mockResolvedValue(undefined);
|
|
const { dispatcher } = createDispatcher();
|
|
|
|
await runDispatch({
|
|
bodyForAgent: "test",
|
|
dispatcher,
|
|
cfg: createAcpTestConfig({
|
|
channels: {
|
|
discord: {
|
|
defaultAccount: "work",
|
|
},
|
|
},
|
|
}),
|
|
ctxOverrides: {
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
},
|
|
sessionKeyOverride: canonicalSessionKey,
|
|
});
|
|
|
|
expect(bindingServiceMocks.listBySession).toHaveBeenCalledWith(canonicalSessionKey);
|
|
expect(dispatcherCall(dispatcher.sendFinalReply, 0).text).toContain("Session ids resolved.");
|
|
expect(dispatcherCall(dispatcher.sendFinalReply, 0).text).toContain(
|
|
"acpx session id: acpx-work",
|
|
);
|
|
});
|
|
|
|
it("does not add a fallback when routed ACP text was already delivered as final", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>);
|
|
const { result } = await runRoutedAcpTextTurn("CODEX_OK");
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(1);
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("routes default ACP text as one final reply to Discord", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies(
|
|
{ text: "Received your test message." },
|
|
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
|
);
|
|
mockRoutedTextTurn("Received your test message.");
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "run acp",
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
originatingChannel: "discord",
|
|
originatingTo: "channel:1478836151241412759",
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(1);
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
|
expect(routeCall().channel).toBe("discord");
|
|
expect(routeCall().to).toBe("channel:1478836151241412759");
|
|
expect(routePayload().text).toBe("Received your test message.");
|
|
});
|
|
|
|
it("routes default ACP text as one final reply to Slack", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies(
|
|
{ text: "Shared update." },
|
|
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
|
);
|
|
mockRoutedTextTurn("Shared update.");
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "run acp",
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
originatingChannel: "slack",
|
|
originatingTo: "channel:C123",
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(1);
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
|
expect(routeCall().channel).toBe("slack");
|
|
expect(routeCall().to).toBe("channel:C123");
|
|
expect(routePayload().text).toBe("Shared update.");
|
|
});
|
|
|
|
it("delivers default Telegram ACP text directly as a final reply", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>);
|
|
mockVisibleTextTurn("CODEX_OK");
|
|
|
|
const { dispatcher, counts } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
},
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(0);
|
|
expect(counts.block).toBe(0);
|
|
expect(counts.final).toBe(0);
|
|
expect(result?.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("CODEX_OK");
|
|
});
|
|
|
|
it("delivers default Discord ACP text directly as a final reply", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies(
|
|
{ text: "Received." },
|
|
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
|
);
|
|
mockVisibleTextTurn("Received.");
|
|
|
|
const { dispatcher, counts } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
},
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(0);
|
|
expect(counts.block).toBe(0);
|
|
expect(counts.final).toBe(0);
|
|
expect(result?.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("Received.");
|
|
});
|
|
|
|
it("delivers default Slack ACP text directly as a final reply", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies(
|
|
{ text: "Slack says hi." },
|
|
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
|
);
|
|
mockVisibleTextTurn("Slack says hi.");
|
|
|
|
const { dispatcher, counts } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
},
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(0);
|
|
expect(counts.block).toBe(0);
|
|
expect(counts.final).toBe(0);
|
|
expect(result?.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("Slack says hi.");
|
|
});
|
|
|
|
it("treats Telegram ACP final delivery as a successful final response", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>);
|
|
mockVisibleTextTurn("CODEX_OK");
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
},
|
|
});
|
|
|
|
expect(result?.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("CODEX_OK");
|
|
});
|
|
|
|
it("delivers default ACP text as final for channels without a visibility override", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>);
|
|
mockVisibleTextTurn("CODEX_OK");
|
|
|
|
const { dispatcher, counts } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
},
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(0);
|
|
expect(counts.block).toBe(0);
|
|
expect(counts.final).toBe(0);
|
|
expect(result?.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("CODEX_OK");
|
|
});
|
|
|
|
it("marks accumulated ACP block TTS finals as trusted local media for WebChat", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies({
|
|
mediaUrl: "/tmp/openclaw-media/acp-tts.ogg",
|
|
audioAsVoice: true,
|
|
} as MockTtsReply);
|
|
mockVisibleTextTurn("WebChat ACP block reply.");
|
|
const cfg = createAcpTestConfig({
|
|
acp: {
|
|
enabled: true,
|
|
stream: {
|
|
deliveryMode: "live",
|
|
coalesceIdleMs: 0,
|
|
maxChunkChars: 64,
|
|
},
|
|
},
|
|
});
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
cfg,
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
},
|
|
});
|
|
|
|
const finalPayload = dispatcherCall(dispatcher.sendFinalReply);
|
|
expect(finalPayload.mediaUrl).toBe("/tmp/openclaw-media/acp-tts.ogg");
|
|
expect(finalPayload.audioAsVoice).toBe(true);
|
|
expect(finalPayload.spokenText).toBe("WebChat ACP block reply.");
|
|
expect(finalPayload.trustedLocalMedia).toBe(true);
|
|
expect(result?.queuedFinal).toBe(true);
|
|
});
|
|
|
|
it("falls back to final text when a later telegram ACP block delivery fails", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies(
|
|
{ text: "First chunk. " },
|
|
{ text: "Second chunk." },
|
|
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
|
);
|
|
const cfg = createAcpTestConfig({
|
|
acp: {
|
|
enabled: true,
|
|
stream: {
|
|
deliveryMode: "live",
|
|
coalesceIdleMs: 0,
|
|
maxChunkChars: 64,
|
|
},
|
|
},
|
|
});
|
|
managerMocks.runTurn.mockImplementation(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await onEvent({ type: "text_delta", text: "First chunk. ", tag: "agent_message_chunk" });
|
|
await onEvent({ type: "text_delta", text: "Second chunk.", tag: "agent_message_chunk" });
|
|
await onEvent({ type: "done" });
|
|
},
|
|
);
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>)
|
|
.mockReturnValueOnce(true)
|
|
.mockReturnValueOnce(false);
|
|
const result = await runDispatch({
|
|
bodyForAgent: "reply",
|
|
cfg,
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
},
|
|
});
|
|
|
|
expect(dispatcherCall(dispatcher.sendBlockReply, 0).text).toBe("First chunk. ");
|
|
expect(dispatcherCall(dispatcher.sendBlockReply, 1).text).toBe("Second chunk.");
|
|
expect(dispatcherCall(dispatcher.sendFinalReply).text).toBe("First chunk. \nSecond chunk.");
|
|
expect(result?.queuedFinal).toBe(true);
|
|
});
|
|
|
|
it("honors the configured default account for ACP projector chunking when AccountId is omitted", async () => {
|
|
setReadyAcpResolution();
|
|
const cfg = createAcpTestConfig({
|
|
acp: {
|
|
enabled: true,
|
|
stream: {
|
|
deliveryMode: "live",
|
|
},
|
|
},
|
|
channels: {
|
|
discord: {
|
|
defaultAccount: "work",
|
|
accounts: {
|
|
work: {
|
|
textChunkLimit: 5,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
managerMocks.runTurn.mockImplementation(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await onEvent({ type: "text_delta", text: "abcdef", tag: "agent_message_chunk" });
|
|
await onEvent({ type: "done" });
|
|
},
|
|
);
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
await runDispatch({
|
|
bodyForAgent: "reply",
|
|
cfg,
|
|
dispatcher,
|
|
ctxOverrides: {
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
},
|
|
});
|
|
|
|
expect(dispatcherCall(dispatcher.sendBlockReply, 0).text).toBe("abcde");
|
|
expect(dispatcherCall(dispatcher.sendBlockReply, 1).text).toBe("f");
|
|
});
|
|
|
|
it("does not add a second routed payload when routed final text was already visible", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
queueTtsReplies({ text: "Task completed" }, {
|
|
mediaUrl: "https://example.com/final.mp3",
|
|
audioAsVoice: true,
|
|
} as MockTtsReply);
|
|
const { result } = await runRoutedAcpTextTurn("Task completed");
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(1);
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
|
expectRoutedPayload(1, {
|
|
text: "Task completed",
|
|
});
|
|
});
|
|
|
|
it("skips fallback when TTS mode is all and final delivery already succeeded", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "all" });
|
|
const { result } = await runRoutedAcpTextTurn("Response");
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(1);
|
|
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("skips final TTS and fallback when no block text was accumulated", async () => {
|
|
setReadyAcpResolution();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
|
|
|
managerMocks.runTurn.mockImplementation(
|
|
async ({ onEvent }: { onEvent: (event: unknown) => Promise<void> }) => {
|
|
await onEvent({ type: "done" });
|
|
},
|
|
);
|
|
|
|
const { dispatcher } = createDispatcher();
|
|
const result = await runDispatch({
|
|
bodyForAgent: "run acp",
|
|
dispatcher,
|
|
shouldRouteToOriginating: true,
|
|
});
|
|
|
|
expect(result?.counts.block).toBe(0);
|
|
expect(result?.counts.final).toBe(0);
|
|
expect(routeMocks.routeReply).not.toHaveBeenCalled();
|
|
expect(ttsMocks.maybeApplyTtsToPayload).not.toHaveBeenCalled();
|
|
});
|
|
});
|