mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 03:52:55 +00:00
Restore visible terminal command replies for explicit command turns that are otherwise source-suppressed in room-event/message-tool-only delivery. Also keep compaction notifyUser notices independent from internal callbacks while preserving hook-message de-duplication. Fixes #87107 Verification: - git diff --check origin/main...HEAD - node scripts/run-vitest.mjs src/auto-reply/reply/dispatch-from-config.test.ts src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts src/auto-reply/reply/agent-runner-execution.test.ts - GitHub required check dependency-guard passed ond3aaad90fc- Relevant GitHub auto-reply/build/lint/type/security checks passed ond3aaad90fcCo-authored-by: openperf <16864032@qq.com>
8595 lines
289 KiB
TypeScript
8595 lines
289 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import {
|
|
clearApprovalNativeRouteStateForTest,
|
|
createApprovalNativeRouteReporter,
|
|
} from "../../infra/approval-native-route-coordinator.js";
|
|
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
|
import type {
|
|
AcpRuntime,
|
|
AcpRuntimeEnsureInput,
|
|
AcpRuntimeEvent,
|
|
AcpRuntimeHandle,
|
|
AcpRuntimeTurnInput,
|
|
} from "../../plugin-sdk/acp-runtime.js";
|
|
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
|
import type {
|
|
PluginHookBeforeDispatchResult,
|
|
PluginHookReplyDispatchResult,
|
|
PluginTargetedInboundClaimOutcome,
|
|
} from "../../plugins/hooks.js";
|
|
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
|
import {
|
|
createChannelTestPluginBase,
|
|
createTestRegistry,
|
|
} from "../../test-utils/channel-plugins.js";
|
|
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
|
import { getReplyPayloadMetadata } from "../reply-payload.js";
|
|
import type { MsgContext } from "../templating.js";
|
|
import { setReplyPayloadMetadata, type GetReplyOptions, type ReplyPayload } from "../types.js";
|
|
import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./provider-request-error-classifier.js";
|
|
import {
|
|
createReplyDispatcher,
|
|
type ReplyDispatchBeforeDeliver,
|
|
type ReplyDispatcher,
|
|
} from "./reply-dispatcher.js";
|
|
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.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(() => ({
|
|
logMessageDispatchCompleted: vi.fn(),
|
|
logMessageDispatchStarted: vi.fn(),
|
|
logMessageQueued: vi.fn(),
|
|
logMessageProcessed: vi.fn(),
|
|
logSessionStateChange: vi.fn(),
|
|
markDiagnosticSessionProgress: vi.fn(),
|
|
}));
|
|
const hookMocks = vi.hoisted(() => ({
|
|
registry: {
|
|
plugins: [] as Array<{
|
|
id: string;
|
|
status: "loaded" | "disabled" | "error";
|
|
}>,
|
|
},
|
|
runner: {
|
|
hasHooks: vi.fn<(hookName?: string) => boolean>(() => false),
|
|
runInboundClaim: vi.fn(async () => undefined),
|
|
runInboundClaimForPlugin: vi.fn(async () => undefined),
|
|
runInboundClaimForPluginOutcome: vi.fn<() => Promise<PluginTargetedInboundClaimOutcome>>(
|
|
async () => ({ status: "no_handler" as const }),
|
|
),
|
|
runMessageReceived: vi.fn(async () => {}),
|
|
runBeforeDispatch: vi.fn<
|
|
(eventValue: unknown, _ctx: unknown) => Promise<PluginHookBeforeDispatchResult | undefined>
|
|
>(async () => undefined),
|
|
runReplyDispatch: vi.fn<
|
|
(eventValue: unknown, _ctx: unknown) => Promise<PluginHookReplyDispatchResult | undefined>
|
|
>(async () => undefined),
|
|
runReplyPayloadSending: vi.fn(async () => undefined),
|
|
},
|
|
}));
|
|
const internalHookMocks = vi.hoisted(() => ({
|
|
createInternalHookEvent: vi.fn(),
|
|
triggerInternalHook: vi.fn(async () => {}),
|
|
}));
|
|
const acpMocks = vi.hoisted(() => ({
|
|
listAcpSessionEntries: vi.fn(async () => []),
|
|
readAcpSessionEntry: vi.fn<(params: { sessionKey: string; cfg?: OpenClawConfig }) => unknown>(
|
|
() => null,
|
|
),
|
|
getAcpRuntimeBackend: vi.fn<() => unknown>(() => null),
|
|
upsertAcpSessionMeta: vi.fn<
|
|
(params: {
|
|
sessionKey: string;
|
|
cfg?: OpenClawConfig;
|
|
mutate: (
|
|
current: Record<string, unknown> | undefined,
|
|
entry: { acp?: Record<string, unknown> } | undefined,
|
|
) => Record<string, unknown> | null | undefined;
|
|
}) => Promise<unknown>
|
|
>(async () => null),
|
|
requireAcpRuntimeBackend: vi.fn<() => unknown>(),
|
|
}));
|
|
const sessionBindingMocks = vi.hoisted(() => ({
|
|
listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []),
|
|
resolveByConversation: vi.fn<
|
|
(ref: {
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
parentConversationId?: string;
|
|
}) => SessionBindingRecord | null
|
|
>(() => null),
|
|
touch: vi.fn(),
|
|
}));
|
|
const pluginConversationBindingMocks = vi.hoisted(() => ({
|
|
shownFallbackNoticeBindingIds: new Set<string>(),
|
|
}));
|
|
const sessionStoreMocks = vi.hoisted(() => ({
|
|
currentEntry: undefined as Record<string, unknown> | undefined,
|
|
loadSessionStore: vi.fn(() => ({})),
|
|
readSessionEntry: vi.fn(() => sessionStoreMocks.currentEntry),
|
|
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
|
|
resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })),
|
|
updateSessionStoreEntry: vi.fn(
|
|
async (params: {
|
|
update: (entry: Record<string, unknown>) => Promise<Record<string, unknown> | null>;
|
|
}) => {
|
|
if (!sessionStoreMocks.currentEntry) {
|
|
return null;
|
|
}
|
|
const patch = await params.update(sessionStoreMocks.currentEntry);
|
|
if (!patch) {
|
|
return sessionStoreMocks.currentEntry;
|
|
}
|
|
sessionStoreMocks.currentEntry = { ...sessionStoreMocks.currentEntry, ...patch };
|
|
return sessionStoreMocks.currentEntry;
|
|
},
|
|
),
|
|
}));
|
|
const acpManagerRuntimeMocks = vi.hoisted(() => ({
|
|
getAcpSessionManager: vi.fn(),
|
|
}));
|
|
const agentEventMocks = vi.hoisted(() => ({
|
|
emitAgentEvent: vi.fn(),
|
|
onAgentEvent: vi.fn<(listener: unknown) => () => void>(() => () => {}),
|
|
}));
|
|
const ttsMocks = vi.hoisted(() => {
|
|
const state = {
|
|
synthesizeFinalAudio: false,
|
|
synthesizeToolAudio: 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,
|
|
trustedLocalMedia: true,
|
|
};
|
|
}
|
|
if (
|
|
state.synthesizeToolAudio &&
|
|
params.kind === "tool" &&
|
|
typeof params.payload?.text === "string" &&
|
|
params.payload.text.trim()
|
|
) {
|
|
return {
|
|
...params.payload,
|
|
mediaUrl: "https://example.com/tts-tool.opus",
|
|
audioAsVoice: true,
|
|
trustedLocalMedia: true,
|
|
};
|
|
}
|
|
return params.payload;
|
|
}),
|
|
normalizeTtsAutoMode: vi.fn((value: unknown) =>
|
|
typeof value === "string" ? value : undefined,
|
|
),
|
|
resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })),
|
|
};
|
|
});
|
|
const transcriptMocks = vi.hoisted(() => ({
|
|
persistAcpDispatchTranscript: vi.fn(async (_params: unknown) => undefined),
|
|
appendAssistantMessageToSessionTranscript: vi.fn(async (_params: unknown) => ({
|
|
ok: true,
|
|
sessionFile: "/tmp/session.jsonl",
|
|
messageId: "message-1",
|
|
})),
|
|
}));
|
|
const replyMediaPathMocks = vi.hoisted(() => ({
|
|
createReplyMediaPathNormalizer: vi.fn(
|
|
(_params?: unknown) => async (payload: ReplyPayload) => payload,
|
|
),
|
|
}));
|
|
const runtimePluginMocks = vi.hoisted(() => ({
|
|
ensureRuntimePluginsLoaded: vi.fn(),
|
|
}));
|
|
const conversationBindingMocks = vi.hoisted(() => {
|
|
type BindingMsgContext = {
|
|
OriginatingChannel?: string | null;
|
|
Surface?: string | null;
|
|
Provider?: string | null;
|
|
AccountId?: string | null;
|
|
MessageThreadId?: string | number | null;
|
|
ThreadParentId?: string | null;
|
|
SenderId?: string | null;
|
|
SessionKey?: string | null;
|
|
ParentSessionKey?: string | null;
|
|
OriginatingTo?: string | null;
|
|
To?: string | null;
|
|
From?: string | null;
|
|
NativeChannelId?: string | null;
|
|
};
|
|
type BindingConfig = {
|
|
channels?: Record<string, { defaultAccount?: string | null } | undefined>;
|
|
};
|
|
|
|
const normalizeText = (value: string | number | null | undefined) =>
|
|
typeof value === "number" ? `${value}` : (value ?? "").trim();
|
|
const normalizeChannel = (value: string | null | undefined) => normalizeText(value).toLowerCase();
|
|
const resolveChannel = (ctx: BindingMsgContext, commandChannel?: string | null) =>
|
|
normalizeChannel(ctx.OriginatingChannel ?? commandChannel ?? ctx.Surface ?? ctx.Provider);
|
|
const resolveAccountId = (ctx: BindingMsgContext, cfg: BindingConfig, channel: string) =>
|
|
normalizeText(ctx.AccountId) ||
|
|
normalizeText(cfg.channels?.[channel]?.defaultAccount) ||
|
|
"default";
|
|
const resolveTarget = (channel: string, value: string | null | undefined) => {
|
|
const target = normalizeText(value);
|
|
if (!target) {
|
|
return undefined;
|
|
}
|
|
const channelPrefix = `${channel}:`;
|
|
return target.toLowerCase().startsWith(channelPrefix)
|
|
? target.slice(channelPrefix.length)
|
|
: target;
|
|
};
|
|
const resolveThreadId = (ctx: BindingMsgContext) =>
|
|
normalizeText(ctx.MessageThreadId) || undefined;
|
|
|
|
const resolveConversationBindingContextFromMessage = vi.fn(
|
|
(params: { cfg: BindingConfig; ctx: BindingMsgContext }) => {
|
|
const channel = resolveChannel(params.ctx);
|
|
if (!channel) {
|
|
return null;
|
|
}
|
|
const threadId = resolveThreadId(params.ctx);
|
|
const baseConversationId =
|
|
resolveTarget(channel, params.ctx.OriginatingTo) ?? resolveTarget(channel, params.ctx.To);
|
|
const conversationId = threadId ?? baseConversationId;
|
|
if (!conversationId) {
|
|
return null;
|
|
}
|
|
const parentConversationId =
|
|
threadId && baseConversationId && baseConversationId !== threadId
|
|
? baseConversationId
|
|
: resolveTarget(channel, params.ctx.ThreadParentId);
|
|
return {
|
|
channel,
|
|
accountId: resolveAccountId(params.ctx, params.cfg, channel),
|
|
conversationId,
|
|
...(parentConversationId ? { parentConversationId } : {}),
|
|
...(threadId ? { threadId } : {}),
|
|
};
|
|
},
|
|
);
|
|
|
|
return {
|
|
resolveConversationBindingAccountIdFromMessage: (params: {
|
|
ctx: BindingMsgContext;
|
|
cfg: BindingConfig;
|
|
commandChannel?: string | null;
|
|
}) =>
|
|
resolveAccountId(params.ctx, params.cfg, resolveChannel(params.ctx, params.commandChannel)),
|
|
resolveConversationBindingChannelFromMessage: (
|
|
ctx: BindingMsgContext,
|
|
commandChannel?: string | null,
|
|
) => resolveChannel(ctx, commandChannel),
|
|
resolveConversationBindingContextFromAcpCommand: (params: {
|
|
cfg: BindingConfig;
|
|
ctx: BindingMsgContext;
|
|
command?: { to?: string | null; senderId?: string | null };
|
|
sessionKey?: string | null;
|
|
parentSessionKey?: string | null;
|
|
}) =>
|
|
resolveConversationBindingContextFromMessage({
|
|
cfg: params.cfg,
|
|
ctx: {
|
|
...params.ctx,
|
|
SenderId: params.command?.senderId ?? params.ctx.SenderId,
|
|
SessionKey: params.sessionKey ?? params.ctx.SessionKey,
|
|
ParentSessionKey: params.parentSessionKey ?? params.ctx.ParentSessionKey,
|
|
To: params.command?.to ?? params.ctx.To,
|
|
},
|
|
}),
|
|
resolveConversationBindingContextFromMessage,
|
|
resolveConversationBindingThreadIdFromMessage: (ctx: BindingMsgContext) => resolveThreadId(ctx),
|
|
};
|
|
});
|
|
const threadInfoMocks = vi.hoisted(() => ({
|
|
parseSessionThreadInfo: vi.fn<
|
|
(sessionKey: string | undefined) => {
|
|
baseSessionKey: string | undefined;
|
|
threadId: string | undefined;
|
|
}
|
|
>(),
|
|
}));
|
|
|
|
function parseGenericThreadSessionInfo(sessionKey: string | undefined) {
|
|
const trimmed = sessionKey?.trim();
|
|
if (!trimmed) {
|
|
return { baseSessionKey: undefined, threadId: undefined };
|
|
}
|
|
const threadMarker = ":thread:";
|
|
const topicMarker = ":topic:";
|
|
const marker = trimmed.includes(threadMarker)
|
|
? threadMarker
|
|
: trimmed.includes(topicMarker)
|
|
? topicMarker
|
|
: undefined;
|
|
if (!marker) {
|
|
return { baseSessionKey: trimmed, threadId: undefined };
|
|
}
|
|
const index = trimmed.lastIndexOf(marker);
|
|
if (index < 0) {
|
|
return { baseSessionKey: trimmed, threadId: undefined };
|
|
}
|
|
const baseSessionKey = trimmed.slice(0, index).trim() || undefined;
|
|
const threadId = trimmed.slice(index + marker.length).trim() || undefined;
|
|
return { baseSessionKey, threadId };
|
|
}
|
|
|
|
vi.mock("./route-reply.runtime.js", () => ({
|
|
isRoutableChannel: (channel: string | undefined) =>
|
|
Boolean(
|
|
channel &&
|
|
[
|
|
"telegram",
|
|
"slack",
|
|
"discord",
|
|
"signal",
|
|
"imessage",
|
|
"whatsapp",
|
|
"feishu",
|
|
"mattermost",
|
|
].includes(channel),
|
|
),
|
|
routeReply: mocks.routeReply,
|
|
}));
|
|
|
|
vi.mock("./route-reply.js", () => ({
|
|
isRoutableChannel: (channel: string | undefined) =>
|
|
Boolean(
|
|
channel &&
|
|
[
|
|
"telegram",
|
|
"slack",
|
|
"discord",
|
|
"signal",
|
|
"imessage",
|
|
"whatsapp",
|
|
"feishu",
|
|
"mattermost",
|
|
].includes(channel),
|
|
),
|
|
routeReply: mocks.routeReply,
|
|
}));
|
|
|
|
vi.mock("./abort.runtime.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", () => ({
|
|
logMessageDispatchCompleted: diagnosticMocks.logMessageDispatchCompleted,
|
|
logMessageDispatchStarted: diagnosticMocks.logMessageDispatchStarted,
|
|
logMessageQueued: diagnosticMocks.logMessageQueued,
|
|
logMessageProcessed: diagnosticMocks.logMessageProcessed,
|
|
logSessionStateChange: diagnosticMocks.logSessionStateChange,
|
|
markDiagnosticSessionProgress: diagnosticMocks.markDiagnosticSessionProgress,
|
|
}));
|
|
vi.mock("../../config/sessions/thread-info.js", () => ({
|
|
parseSessionThreadInfo: (sessionKey: string | undefined) =>
|
|
threadInfoMocks.parseSessionThreadInfo(sessionKey),
|
|
parseSessionThreadInfoFast: (sessionKey: string | undefined) =>
|
|
threadInfoMocks.parseSessionThreadInfo(sessionKey),
|
|
}));
|
|
vi.mock("./dispatch-from-config.runtime.js", () => ({
|
|
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
|
loadSessionStore: sessionStoreMocks.loadSessionStore,
|
|
readSessionEntry: sessionStoreMocks.readSessionEntry,
|
|
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
|
|
resolveStorePath: sessionStoreMocks.resolveStorePath,
|
|
triggerInternalHook: internalHookMocks.triggerInternalHook,
|
|
updateSessionStoreEntry: sessionStoreMocks.updateSessionStoreEntry,
|
|
}));
|
|
|
|
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
|
initializeGlobalHookRunner: vi.fn(),
|
|
getGlobalHookRunner: () => hookMocks.runner,
|
|
getGlobalPluginRegistry: () => hookMocks.registry,
|
|
resetGlobalHookRunner: vi.fn(),
|
|
}));
|
|
vi.mock("../../acp/runtime/session-meta.js", () => ({
|
|
listAcpSessionEntries: acpMocks.listAcpSessionEntries,
|
|
readAcpSessionEntry: acpMocks.readAcpSessionEntry,
|
|
upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta,
|
|
}));
|
|
vi.mock("../../acp/runtime/registry.js", () => ({
|
|
getAcpRuntimeBackend: acpMocks.getAcpRuntimeBackend,
|
|
requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend,
|
|
}));
|
|
vi.mock("../../infra/outbound/session-binding-service.js", () => ({
|
|
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: sessionBindingMocks.resolveByConversation,
|
|
touch: sessionBindingMocks.touch,
|
|
unbind: vi.fn(async () => []),
|
|
}),
|
|
}));
|
|
vi.mock("../../infra/agent-events.js", () => ({
|
|
emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
|
|
onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener),
|
|
}));
|
|
vi.mock("../../plugins/conversation-binding.js", () => ({
|
|
buildPluginBindingDeclinedText: () => "Plugin binding request was declined.",
|
|
buildPluginBindingErrorText: () => "Plugin binding request failed.",
|
|
buildPluginBindingUnavailableText: (binding: { pluginName?: string; pluginId: string }) =>
|
|
`${binding.pluginName ?? binding.pluginId} is not currently loaded.`,
|
|
hasShownPluginBindingFallbackNotice: (bindingId: string) =>
|
|
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.has(bindingId),
|
|
isPluginOwnedSessionBindingRecord: (
|
|
record: SessionBindingRecord | null | undefined,
|
|
): record is SessionBindingRecord =>
|
|
record?.metadata != null &&
|
|
typeof record.metadata === "object" &&
|
|
(record.metadata as { pluginBindingOwner?: string }).pluginBindingOwner === "plugin",
|
|
markPluginBindingFallbackNoticeShown: (bindingId: string) => {
|
|
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.add(bindingId);
|
|
},
|
|
toPluginConversationBinding: (record: SessionBindingRecord) => {
|
|
const metadata = (record.metadata ?? {}) as {
|
|
pluginId?: string;
|
|
pluginName?: string;
|
|
pluginRoot?: string;
|
|
data?: Record<string, unknown>;
|
|
};
|
|
return {
|
|
bindingId: record.bindingId,
|
|
pluginId: metadata.pluginId ?? "unknown-plugin",
|
|
pluginName: metadata.pluginName,
|
|
pluginRoot: metadata.pluginRoot ?? "",
|
|
channel: record.conversation.channel,
|
|
accountId: record.conversation.accountId,
|
|
conversationId: record.conversation.conversationId,
|
|
parentConversationId: record.conversation.parentConversationId,
|
|
data: metadata.data,
|
|
};
|
|
},
|
|
}));
|
|
vi.mock("./dispatch-acp-manager.runtime.js", () => ({
|
|
getAcpSessionManager: () => acpManagerRuntimeMocks.getAcpSessionManager(),
|
|
getSessionBindingService: () => ({
|
|
listBySession: (targetSessionKey: string) =>
|
|
sessionBindingMocks.listBySession(targetSessionKey),
|
|
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),
|
|
}));
|
|
vi.mock("../../tts/tts.runtime.js", () => ({
|
|
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
|
|
}));
|
|
vi.mock("./reply-media-paths.runtime.js", () => ({
|
|
createReplyMediaPathNormalizer: (params: unknown) =>
|
|
replyMediaPathMocks.createReplyMediaPathNormalizer(params),
|
|
}));
|
|
vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({
|
|
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
|
|
}));
|
|
vi.mock("./conversation-binding-input.js", () => ({
|
|
resolveConversationBindingAccountIdFromMessage:
|
|
conversationBindingMocks.resolveConversationBindingAccountIdFromMessage,
|
|
resolveConversationBindingChannelFromMessage:
|
|
conversationBindingMocks.resolveConversationBindingChannelFromMessage,
|
|
resolveConversationBindingContextFromAcpCommand:
|
|
conversationBindingMocks.resolveConversationBindingContextFromAcpCommand,
|
|
resolveConversationBindingContextFromMessage:
|
|
conversationBindingMocks.resolveConversationBindingContextFromMessage,
|
|
resolveConversationBindingThreadIdFromMessage:
|
|
conversationBindingMocks.resolveConversationBindingThreadIdFromMessage,
|
|
}));
|
|
vi.mock("../../tts/status-config.js", () => ({
|
|
resolveStatusTtsSnapshot: () => ({
|
|
autoMode: "always",
|
|
provider: "auto",
|
|
maxLength: 1500,
|
|
summarize: true,
|
|
}),
|
|
}));
|
|
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
|
|
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
|
|
}));
|
|
vi.mock("./dispatch-acp-transcript.runtime.js", () => ({
|
|
persistAcpDispatchTranscript: (params: unknown) =>
|
|
transcriptMocks.persistAcpDispatchTranscript(params),
|
|
}));
|
|
vi.mock("../../config/sessions/transcript.js", () => ({
|
|
appendAssistantMessageToSessionTranscript: (params: unknown) =>
|
|
transcriptMocks.appendAssistantMessageToSessionTranscript(params),
|
|
}));
|
|
vi.mock("./dispatch-acp-session.runtime.js", () => ({
|
|
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
|
|
acpMocks.readAcpSessionEntry(params),
|
|
}));
|
|
vi.mock("../../tts/tts-config.js", () => ({
|
|
normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value),
|
|
resolveConfiguredTtsMode: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg).mode,
|
|
shouldCleanTtsDirectiveText: () => true,
|
|
shouldAttemptTtsPayload: () => true,
|
|
}));
|
|
|
|
const noAbortResult = { handled: false, aborted: false } as const;
|
|
const emptyConfig = {} as OpenClawConfig;
|
|
const automaticGroupReplyConfig = {
|
|
messages: {
|
|
groupChat: {
|
|
visibleReplies: "automatic",
|
|
},
|
|
},
|
|
} as const satisfies OpenClawConfig;
|
|
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
|
let dispatchFromConfigTesting: typeof import("./dispatch-from-config.js").testing;
|
|
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
|
let tryDispatchAcpReplyHook: typeof import("../../plugin-sdk/acp-runtime.js").tryDispatchAcpReplyHook;
|
|
let createReplyOperation: typeof import("./reply-run-registry.js").createReplyOperation;
|
|
let replyRunRegistry: typeof import("./reply-run-registry.js").replyRunRegistry;
|
|
let replyRunTesting: typeof import("./reply-run-registry.js").__testing;
|
|
type DispatchReplyArgs = Parameters<
|
|
typeof import("./dispatch-from-config.js").dispatchReplyFromConfig
|
|
>[0];
|
|
|
|
beforeAll(async () => {
|
|
({ dispatchReplyFromConfig, testing: dispatchFromConfigTesting } =
|
|
await import("./dispatch-from-config.js"));
|
|
await import("./dispatch-acp.js");
|
|
await import("./dispatch-acp-command-bypass.js");
|
|
await import("./dispatch-acp-tts.runtime.js");
|
|
await import("./dispatch-acp-session.runtime.js");
|
|
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
|
|
({ tryDispatchAcpReplyHook } = await import("../../plugin-sdk/acp-runtime.js"));
|
|
({
|
|
createReplyOperation,
|
|
replyRunRegistry,
|
|
__testing: replyRunTesting,
|
|
} = await import("./reply-run-registry.js"));
|
|
});
|
|
|
|
function createDispatcher(): ReplyDispatcher {
|
|
let beforeDeliver: ReplyDispatchBeforeDeliver | undefined;
|
|
const beforeDeliverTasks: Promise<unknown>[] = [];
|
|
const runBeforeDeliver = (kind: "tool" | "block" | "final", payload: ReplyPayload): void => {
|
|
if (!beforeDeliver) {
|
|
return;
|
|
}
|
|
beforeDeliverTasks.push(Promise.resolve(beforeDeliver(payload, { kind })));
|
|
};
|
|
return {
|
|
sendToolResult: vi.fn((payload) => {
|
|
runBeforeDeliver("tool", payload);
|
|
return true;
|
|
}),
|
|
sendBlockReply: vi.fn((payload) => {
|
|
runBeforeDeliver("block", payload);
|
|
return true;
|
|
}),
|
|
sendFinalReply: vi.fn((payload) => {
|
|
runBeforeDeliver("final", payload);
|
|
return true;
|
|
}),
|
|
appendBeforeDeliver: vi.fn((hook) => {
|
|
const previousBeforeDeliver = beforeDeliver;
|
|
beforeDeliver = previousBeforeDeliver
|
|
? async (payload, info) => {
|
|
const previousPayload = await previousBeforeDeliver(payload, info);
|
|
return previousPayload ? hook(previousPayload, info) : null;
|
|
}
|
|
: hook;
|
|
}),
|
|
waitForIdle: vi.fn(async () => {
|
|
await Promise.all(beforeDeliverTasks);
|
|
}),
|
|
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
markComplete: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function shouldUseAcpReplyDispatchHook(eventUnknown: unknown): boolean {
|
|
const event = eventUnknown as {
|
|
sessionKey?: string;
|
|
ctx?: {
|
|
SessionKey?: string;
|
|
CommandTargetSessionKey?: string;
|
|
AcpDispatchTailAfterReset?: boolean;
|
|
};
|
|
};
|
|
if (event.ctx?.AcpDispatchTailAfterReset) {
|
|
return true;
|
|
}
|
|
return [event.sessionKey, event.ctx?.SessionKey, event.ctx?.CommandTargetSessionKey].some(
|
|
(value) => {
|
|
const key = value?.trim();
|
|
return Boolean(key && (key.includes("acp:") || key.includes(":acp") || key.includes("-acp")));
|
|
},
|
|
);
|
|
}
|
|
|
|
function setNoAbort() {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue(noAbortResult);
|
|
}
|
|
|
|
type MockAcpRuntime = AcpRuntime & {
|
|
ensureSession: Mock<(input: AcpRuntimeEnsureInput) => Promise<AcpRuntimeHandle>>;
|
|
runTurn: Mock<(input: AcpRuntimeTurnInput) => AsyncIterable<AcpRuntimeEvent>>;
|
|
cancel: Mock<(input: { handle: AcpRuntimeHandle; reason?: string }) => Promise<void>>;
|
|
close: Mock<(input: { handle: AcpRuntimeHandle; reason: string }) => Promise<void>>;
|
|
};
|
|
|
|
function createAcpRuntime(events: AcpRuntimeEvent[]): MockAcpRuntime {
|
|
const runtime = {
|
|
ensureSession: vi.fn<(input: AcpRuntimeEnsureInput) => Promise<AcpRuntimeHandle>>(
|
|
async (input) => ({
|
|
sessionKey: input.sessionKey,
|
|
backend: "acpx",
|
|
runtimeSessionName: `${input.sessionKey}:${input.mode}`,
|
|
}),
|
|
),
|
|
runTurn: vi.fn<(input: AcpRuntimeTurnInput) => AsyncIterable<AcpRuntimeEvent>>(
|
|
async function* (_input) {
|
|
for (const event of events) {
|
|
yield event;
|
|
}
|
|
},
|
|
),
|
|
cancel: vi.fn<(input: { handle: AcpRuntimeHandle; reason?: string }) => Promise<void>>(
|
|
async () => {},
|
|
),
|
|
close: vi.fn<(input: { handle: AcpRuntimeHandle; reason: string }) => Promise<void>>(
|
|
async () => {},
|
|
),
|
|
} satisfies AcpRuntime;
|
|
return runtime as MockAcpRuntime;
|
|
}
|
|
|
|
function createMockAcpSessionManager() {
|
|
return {
|
|
resolveSession: (params: { cfg: OpenClawConfig; sessionKey: string }) => {
|
|
const entry = acpMocks.readAcpSessionEntry({
|
|
cfg: params.cfg,
|
|
sessionKey: params.sessionKey,
|
|
}) as { acp?: Record<string, unknown> } | null;
|
|
if (entry?.acp) {
|
|
return {
|
|
kind: "ready" as const,
|
|
sessionKey: params.sessionKey,
|
|
meta: entry.acp,
|
|
};
|
|
}
|
|
return params.sessionKey.startsWith("agent:")
|
|
? {
|
|
kind: "stale" as const,
|
|
sessionKey: params.sessionKey,
|
|
error: {
|
|
code: "ACP_SESSION_INIT_FAILED",
|
|
message: `ACP metadata is missing for ${params.sessionKey}.`,
|
|
},
|
|
}
|
|
: {
|
|
kind: "none" as const,
|
|
sessionKey: params.sessionKey,
|
|
};
|
|
},
|
|
getObservabilitySnapshot: () => ({
|
|
runtimeCache: {
|
|
activeSessions: 0,
|
|
idleTtlMs: 0,
|
|
evictedTotal: 0,
|
|
},
|
|
turns: {
|
|
active: 0,
|
|
queueDepth: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
averageLatencyMs: 0,
|
|
maxLatencyMs: 0,
|
|
},
|
|
errorsByCode: {},
|
|
}),
|
|
runTurn: vi.fn(
|
|
async (params: {
|
|
cfg: OpenClawConfig;
|
|
sessionKey: string;
|
|
text?: string;
|
|
attachments?: unknown[];
|
|
mode: string;
|
|
requestId: string;
|
|
signal?: AbortSignal;
|
|
onEvent: (event: Record<string, unknown>) => Promise<void>;
|
|
}) => {
|
|
const entry = acpMocks.readAcpSessionEntry({
|
|
cfg: params.cfg,
|
|
sessionKey: params.sessionKey,
|
|
}) as {
|
|
acp?: {
|
|
agent?: string;
|
|
mode?: string;
|
|
};
|
|
} | null;
|
|
const runtimeBackend = acpMocks.requireAcpRuntimeBackend() as {
|
|
runtime?: ReturnType<typeof createAcpRuntime>;
|
|
};
|
|
if (!runtimeBackend.runtime) {
|
|
throw new Error("ACP runtime backend not mocked");
|
|
}
|
|
const handle = await runtimeBackend.runtime.ensureSession({
|
|
sessionKey: params.sessionKey,
|
|
mode: (entry?.acp?.mode || "persistent") as AcpRuntimeEnsureInput["mode"],
|
|
agent: entry?.acp?.agent || "codex",
|
|
});
|
|
const stream = runtimeBackend.runtime.runTurn({
|
|
handle,
|
|
text: params.text ?? "",
|
|
attachments: params.attachments as AcpRuntimeTurnInput["attachments"],
|
|
mode: params.mode as AcpRuntimeTurnInput["mode"],
|
|
requestId: params.requestId,
|
|
signal: params.signal,
|
|
});
|
|
for await (const event of stream) {
|
|
await params.onEvent(event);
|
|
}
|
|
if (entry?.acp?.mode === "oneshot") {
|
|
await runtimeBackend.runtime.close({
|
|
handle,
|
|
reason: "oneshot-complete",
|
|
});
|
|
}
|
|
},
|
|
),
|
|
};
|
|
}
|
|
|
|
function firstMockCall(mockFn: ReturnType<typeof vi.fn>, label: string, index = 0): unknown[] {
|
|
const call = mockFn.mock.calls[index] as unknown[] | undefined;
|
|
if (!call) {
|
|
throw new Error(`expected ${label} call #${index + 1}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function firstMockArg(
|
|
mockFn: ReturnType<typeof vi.fn>,
|
|
label: string,
|
|
index = 0,
|
|
argIndex = 0,
|
|
): unknown {
|
|
return firstMockCall(mockFn, label, index)[argIndex];
|
|
}
|
|
|
|
function firstToolResultPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined {
|
|
return firstMockArg(
|
|
dispatcher.sendToolResult as ReturnType<typeof vi.fn>,
|
|
"tool result",
|
|
) as ReplyPayload;
|
|
}
|
|
|
|
function firstFinalReplyPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined {
|
|
return firstMockArg(
|
|
dispatcher.sendFinalReply as ReturnType<typeof vi.fn>,
|
|
"final reply",
|
|
) as ReplyPayload;
|
|
}
|
|
|
|
function firstRouteReplyCall(): Record<string, unknown> {
|
|
const call = firstMockArg(mocks.routeReply, "route reply");
|
|
if (!call || typeof call !== "object") {
|
|
throw new Error("expected route reply params");
|
|
}
|
|
return call as Record<string, unknown>;
|
|
}
|
|
|
|
function requireToolResultHandler(
|
|
handler: GetReplyOptions["onToolResult"] | undefined,
|
|
): NonNullable<GetReplyOptions["onToolResult"]> {
|
|
if (typeof handler !== "function") {
|
|
throw new Error("expected onToolResult handler");
|
|
}
|
|
return handler;
|
|
}
|
|
|
|
function requireBlockReplyHandler(
|
|
handler: GetReplyOptions["onBlockReply"] | undefined,
|
|
): NonNullable<GetReplyOptions["onBlockReply"]> {
|
|
if (typeof handler !== "function") {
|
|
throw new Error("expected onBlockReply handler");
|
|
}
|
|
return handler;
|
|
}
|
|
|
|
async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs, "dispatcher">) {
|
|
const first = await dispatchReplyFromConfig({
|
|
...params,
|
|
dispatcher: createDispatcher(),
|
|
});
|
|
const second = await dispatchReplyFromConfig({
|
|
...params,
|
|
dispatcher: createDispatcher(),
|
|
});
|
|
return [first, second] as const;
|
|
}
|
|
|
|
describe("dispatchReplyFromConfig", () => {
|
|
beforeEach(() => {
|
|
clearAgentHarnesses();
|
|
clearPluginCommands();
|
|
const discordTestPlugin = {
|
|
...createChannelTestPluginBase({
|
|
id: "discord",
|
|
capabilities: {
|
|
chatTypes: ["direct"],
|
|
nativeCommands: true,
|
|
},
|
|
}),
|
|
outbound: {
|
|
deliveryMode: "direct",
|
|
shouldSuppressLocalPayloadPrompt: ({
|
|
payload,
|
|
hint,
|
|
}: {
|
|
payload: ReplyPayload;
|
|
hint?: { nativeRouteActive?: boolean };
|
|
}) =>
|
|
hint?.nativeRouteActive === true &&
|
|
Boolean(
|
|
payload.channelData &&
|
|
typeof payload.channelData === "object" &&
|
|
!Array.isArray(payload.channelData) &&
|
|
payload.channelData.execApproval,
|
|
),
|
|
},
|
|
};
|
|
const signalTestPlugin = {
|
|
...createChannelTestPluginBase({
|
|
id: "signal",
|
|
capabilities: {
|
|
chatTypes: ["direct"],
|
|
nativeCommands: true,
|
|
},
|
|
}),
|
|
outbound: {
|
|
deliveryMode: "direct",
|
|
shouldSuppressLocalPayloadPrompt: ({
|
|
cfg,
|
|
payload,
|
|
hint,
|
|
}: {
|
|
cfg: OpenClawConfig;
|
|
payload: ReplyPayload;
|
|
hint?: { kind?: string; approvalKind?: string; nativeRouteActive?: boolean };
|
|
}) =>
|
|
hint?.kind === "approval-pending" &&
|
|
hint.approvalKind === "exec" &&
|
|
hint.nativeRouteActive === true &&
|
|
cfg.approvals?.exec?.enabled === true &&
|
|
Boolean(
|
|
payload.channelData &&
|
|
typeof payload.channelData === "object" &&
|
|
!Array.isArray(payload.channelData) &&
|
|
payload.channelData.execApproval,
|
|
),
|
|
},
|
|
};
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "discord",
|
|
source: "test",
|
|
plugin: discordTestPlugin,
|
|
},
|
|
{
|
|
pluginId: "signal",
|
|
source: "test",
|
|
plugin: signalTestPlugin,
|
|
},
|
|
]),
|
|
);
|
|
clearApprovalNativeRouteStateForTest();
|
|
acpManagerRuntimeMocks.getAcpSessionManager.mockReset();
|
|
acpManagerRuntimeMocks.getAcpSessionManager.mockReturnValue(createMockAcpSessionManager());
|
|
replyRunTesting.resetReplyRunRegistry();
|
|
resetInboundDedupe();
|
|
mocks.routeReply.mockReset();
|
|
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
|
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
|
|
diagnosticMocks.logMessageQueued.mockClear();
|
|
diagnosticMocks.logMessageProcessed.mockClear();
|
|
diagnosticMocks.logSessionStateChange.mockClear();
|
|
diagnosticMocks.markDiagnosticSessionProgress.mockClear();
|
|
diagnosticMocks.logMessageDispatchStarted.mockClear();
|
|
diagnosticMocks.logMessageDispatchCompleted.mockClear();
|
|
hookMocks.runner.hasHooks.mockClear();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
(hookName?: string) => hookName === "reply_dispatch",
|
|
);
|
|
hookMocks.runner.runInboundClaim.mockClear();
|
|
hookMocks.runner.runInboundClaim.mockResolvedValue(undefined);
|
|
hookMocks.runner.runInboundClaimForPlugin.mockClear();
|
|
hookMocks.runner.runInboundClaimForPlugin.mockResolvedValue(undefined);
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "no_handler",
|
|
});
|
|
hookMocks.runner.runMessageReceived.mockClear();
|
|
hookMocks.runner.runBeforeDispatch.mockClear();
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue(undefined);
|
|
hookMocks.runner.runReplyDispatch.mockClear();
|
|
hookMocks.runner.runReplyDispatch.mockImplementation(async (event: unknown, ctx: unknown) => {
|
|
if (!shouldUseAcpReplyDispatchHook(event)) {
|
|
return undefined;
|
|
}
|
|
return (await tryDispatchAcpReplyHook(event as never, ctx as never)) ?? undefined;
|
|
});
|
|
hookMocks.registry.plugins = [];
|
|
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.getAcpRuntimeBackend.mockReset();
|
|
acpMocks.requireAcpRuntimeBackend.mockReset();
|
|
agentEventMocks.emitAgentEvent.mockReset();
|
|
agentEventMocks.onAgentEvent.mockReset();
|
|
agentEventMocks.onAgentEvent.mockReturnValue(() => {});
|
|
sessionBindingMocks.listBySession.mockReset();
|
|
sessionBindingMocks.listBySession.mockReturnValue([]);
|
|
pluginConversationBindingMocks.shownFallbackNoticeBindingIds.clear();
|
|
sessionBindingMocks.resolveByConversation.mockReset();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue(null);
|
|
sessionBindingMocks.touch.mockReset();
|
|
sessionStoreMocks.currentEntry = undefined;
|
|
sessionStoreMocks.loadSessionStore.mockClear();
|
|
sessionStoreMocks.readSessionEntry.mockReset();
|
|
sessionStoreMocks.readSessionEntry.mockImplementation(() => sessionStoreMocks.currentEntry);
|
|
sessionStoreMocks.resolveStorePath.mockClear();
|
|
sessionStoreMocks.resolveSessionStoreEntry.mockClear();
|
|
threadInfoMocks.parseSessionThreadInfo.mockReset();
|
|
threadInfoMocks.parseSessionThreadInfo.mockImplementation(parseGenericThreadSessionInfo);
|
|
ttsMocks.state.synthesizeFinalAudio = false;
|
|
ttsMocks.state.synthesizeToolAudio = false;
|
|
ttsMocks.maybeApplyTtsToPayload.mockClear();
|
|
ttsMocks.normalizeTtsAutoMode.mockClear();
|
|
ttsMocks.resolveTtsConfig.mockClear();
|
|
ttsMocks.resolveTtsConfig.mockReturnValue({
|
|
mode: "final",
|
|
});
|
|
transcriptMocks.persistAcpDispatchTranscript.mockClear();
|
|
transcriptMocks.appendAssistantMessageToSessionTranscript.mockClear();
|
|
replyMediaPathMocks.createReplyMediaPathNormalizer.mockReset();
|
|
replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue(
|
|
async (payload: ReplyPayload) => payload,
|
|
);
|
|
runtimePluginMocks.ensureRuntimePluginsLoaded.mockClear();
|
|
});
|
|
|
|
it("loads runtime plugins before reading inbound hook state", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
const pluginLoadOptions = firstMockArg(
|
|
runtimePluginMocks.ensureRuntimePluginsLoaded,
|
|
"runtime plugin load",
|
|
) as { config?: unknown; workspaceDir?: unknown };
|
|
expect(pluginLoadOptions.config).toBe(cfg);
|
|
expect(typeof pluginLoadOptions.workspaceDir).toBe("string");
|
|
expect(runtimePluginMocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan(
|
|
hookMocks.runner.hasHooks.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
|
|
it("skips pre-dispatch admission when the caller already aborted", async () => {
|
|
setNoAbort();
|
|
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:3731";
|
|
const activeOperation = createReplyOperation({
|
|
sessionKey,
|
|
sessionId: "active-session",
|
|
resetTriggered: false,
|
|
});
|
|
activeOperation.setPhase("running");
|
|
const abortController = new AbortController();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
abortController.abort();
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
SessionKey: sessionKey,
|
|
ChatType: "group",
|
|
IsForum: true,
|
|
MessageSid: "27784",
|
|
MessageThreadId: 3731,
|
|
TransportThreadId: 3731,
|
|
To: "telegram:-1003774691294:topic:3731",
|
|
BodyForAgent: "superseded while waiting",
|
|
}),
|
|
cfg: automaticGroupReplyConfig,
|
|
dispatcher,
|
|
replyOptions: { abortSignal: abortController.signal },
|
|
replyResolver,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
queuedFinal: false,
|
|
counts: { tool: 0, block: 0, final: 0 },
|
|
});
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
activeOperation.complete();
|
|
});
|
|
|
|
it("skips a Telegram topic heartbeat turn while a reply operation is active", async () => {
|
|
setNoAbort();
|
|
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:3731";
|
|
const activeOperation = createReplyOperation({
|
|
sessionKey,
|
|
sessionId: "user-session",
|
|
resetTriggered: false,
|
|
});
|
|
activeOperation.setPhase("running");
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(
|
|
async () => ({ text: "heartbeat should not run" }) satisfies ReplyPayload,
|
|
);
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
SessionKey: sessionKey,
|
|
ChatType: "group",
|
|
IsForum: true,
|
|
MessageSid: "heartbeat",
|
|
MessageThreadId: 3731,
|
|
TransportThreadId: 3731,
|
|
To: "telegram:-1003774691294:topic:3731",
|
|
BodyForAgent: "[OpenClaw heartbeat poll]",
|
|
}),
|
|
cfg: automaticGroupReplyConfig,
|
|
dispatcher,
|
|
replyOptions: { isHeartbeat: true },
|
|
replyResolver,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
queuedFinal: false,
|
|
counts: { tool: 0, block: 0, final: 0 },
|
|
});
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(replyRunRegistry.get(sessionKey)).toBe(activeOperation);
|
|
activeOperation.complete();
|
|
});
|
|
|
|
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,
|
|
GroupChannel: "ops-room",
|
|
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();
|
|
const routeCall = firstRouteReplyCall() as
|
|
| {
|
|
accountId?: unknown;
|
|
channel?: unknown;
|
|
groupId?: unknown;
|
|
isGroup?: unknown;
|
|
threadId?: unknown;
|
|
to?: unknown;
|
|
}
|
|
| undefined;
|
|
expect(routeCall?.channel).toBe("telegram");
|
|
expect(routeCall?.to).toBe("telegram:999");
|
|
expect(routeCall?.accountId).toBe("acc-1");
|
|
expect(routeCall?.threadId).toBe(123);
|
|
expect(routeCall?.isGroup).toBe(true);
|
|
expect(routeCall?.groupId).toBe("telegram:999");
|
|
});
|
|
|
|
it("routes exec-event replies using persisted session delivery context when current turn has no originating route", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
sessionStoreMocks.currentEntry = {
|
|
deliveryContext: {
|
|
channel: "telegram",
|
|
to: "telegram:999",
|
|
accountId: "acc-1",
|
|
},
|
|
lastChannel: "telegram",
|
|
lastTo: "telegram:999",
|
|
lastAccountId: "acc-1",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "exec-event",
|
|
Surface: "exec-event",
|
|
SessionKey: "agent:main:main",
|
|
AccountId: undefined,
|
|
OriginatingChannel: undefined,
|
|
OriginatingTo: undefined,
|
|
});
|
|
|
|
const replyResolver = async () =>
|
|
({ text: "hi", mediaUrl: "https://example.test/reply.png" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
const routeCall = firstRouteReplyCall() as
|
|
| { accountId?: unknown; channel?: unknown; to?: unknown }
|
|
| undefined;
|
|
expect(routeCall?.channel).toBe("telegram");
|
|
expect(routeCall?.to).toBe("telegram:999");
|
|
expect(routeCall?.accountId).toBe("acc-1");
|
|
const normalizerOptions = replyMediaPathMocks.createReplyMediaPathNormalizer.mock
|
|
.calls[0]?.[0] as { accountId?: unknown; messageProvider?: unknown } | undefined;
|
|
expect(normalizerOptions?.messageProvider).toBe("telegram");
|
|
expect(normalizerOptions?.accountId).toBe("acc-1");
|
|
const replyDispatchCall = firstMockCall(hookMocks.runner.runReplyDispatch, "reply dispatch") as
|
|
| [
|
|
{
|
|
originatingChannel?: unknown;
|
|
originatingTo?: unknown;
|
|
shouldRouteToOriginating?: unknown;
|
|
},
|
|
unknown,
|
|
]
|
|
| undefined;
|
|
expect(replyDispatchCall?.[0]?.shouldRouteToOriginating).toBe(true);
|
|
expect(replyDispatchCall?.[0]?.originatingChannel).toBe("telegram");
|
|
expect(replyDispatchCall?.[0]?.originatingTo).toBe("telegram:999");
|
|
expect(typeof replyDispatchCall?.[1]).toBe("object");
|
|
});
|
|
|
|
it("routes exec-event replies using last route fields when delivery context is missing", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
sessionStoreMocks.currentEntry = {
|
|
lastChannel: "discord",
|
|
lastTo: "channel:123",
|
|
lastAccountId: "default",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "exec-event",
|
|
Surface: "exec-event",
|
|
SessionKey: "agent:main:main",
|
|
AccountId: undefined,
|
|
OriginatingChannel: undefined,
|
|
OriginatingTo: undefined,
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
const routeCall = firstRouteReplyCall() as
|
|
| { accountId?: unknown; channel?: unknown; to?: unknown }
|
|
| undefined;
|
|
expect(routeCall?.channel).toBe("discord");
|
|
expect(routeCall?.to).toBe("channel:123");
|
|
expect(routeCall?.accountId).toBe("default");
|
|
});
|
|
|
|
it("honors sendPolicy deny for recovered exec-event delivery channel", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
sessionStoreMocks.currentEntry = {
|
|
deliveryContext: {
|
|
channel: "telegram",
|
|
to: "telegram:999",
|
|
accountId: "acc-1",
|
|
},
|
|
lastChannel: "telegram",
|
|
lastTo: "telegram:999",
|
|
lastAccountId: "acc-1",
|
|
};
|
|
const cfg = {
|
|
session: {
|
|
sendPolicy: {
|
|
default: "allow",
|
|
rules: [{ action: "deny", match: { channel: "telegram" } }],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "exec-event",
|
|
Surface: "exec-event",
|
|
SessionKey: "agent:main:main",
|
|
AccountId: undefined,
|
|
OriginatingChannel: undefined,
|
|
OriginatingTo: undefined,
|
|
});
|
|
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) satisfies ReplyPayload);
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(result.queuedFinal).toBe(false);
|
|
const replyDispatchCall = firstMockCall(hookMocks.runner.runReplyDispatch, "reply dispatch") as
|
|
| [
|
|
{
|
|
originatingChannel?: unknown;
|
|
originatingTo?: unknown;
|
|
sendPolicy?: unknown;
|
|
shouldRouteToOriginating?: unknown;
|
|
suppressUserDelivery?: unknown;
|
|
},
|
|
unknown,
|
|
]
|
|
| undefined;
|
|
expect(replyDispatchCall?.[0]?.sendPolicy).toBe("deny");
|
|
expect(replyDispatchCall?.[0]?.suppressUserDelivery).toBe(true);
|
|
expect(replyDispatchCall?.[0]?.shouldRouteToOriginating).toBe(true);
|
|
expect(replyDispatchCall?.[0]?.originatingChannel).toBe("telegram");
|
|
expect(replyDispatchCall?.[0]?.originatingTo).toBe("telegram:999");
|
|
expect(typeof replyDispatchCall?.[1]).toBe("object");
|
|
});
|
|
|
|
it("falls back to thread-scoped session key when current ctx has no MessageThreadId", async () => {
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
SessionKey: "agent:main:discord:channel:CHAN1:thread:post-root",
|
|
AccountId: "default",
|
|
MessageThreadId: undefined,
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "channel:CHAN1",
|
|
ExplicitDeliverRoute: true,
|
|
});
|
|
|
|
expect(resolveRoutedDeliveryThreadId({ ctx, sessionKey: ctx.SessionKey })).toBe("post-root");
|
|
});
|
|
|
|
it("uses Slack DM TransportThreadId when ReplyToId is the current message", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
SessionKey: "agent:main:slack:direct:u123",
|
|
AccountId: "default",
|
|
ChatType: "direct",
|
|
MessageSid: "101.000",
|
|
ReplyToId: "101.000",
|
|
TransportThreadId: "101.000",
|
|
MessageThreadId: undefined,
|
|
OriginatingChannel: "slack",
|
|
OriginatingTo: "user:U123",
|
|
ExplicitDeliverRoute: true,
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
const routeCall = firstRouteReplyCall() as { threadId?: string | number } | undefined;
|
|
expect(routeCall?.threadId).toBe("101.000");
|
|
});
|
|
|
|
it("does not resurrect a cleared route thread from origin metadata", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
// Simulate the real store: lastThreadId and deliveryContext.threadId may be normalised from
|
|
// origin.threadId on read, but a non-thread session key must still route to channel root.
|
|
sessionStoreMocks.currentEntry = {
|
|
deliveryContext: {
|
|
channel: "mattermost",
|
|
to: "channel:CHAN1",
|
|
accountId: "default",
|
|
threadId: "stale-root",
|
|
},
|
|
lastThreadId: "stale-root",
|
|
origin: {
|
|
threadId: "stale-root",
|
|
},
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
SessionKey: "agent:main:mattermost:channel:CHAN1",
|
|
AccountId: "default",
|
|
MessageThreadId: undefined,
|
|
OriginatingChannel: "mattermost",
|
|
OriginatingTo: "channel:CHAN1",
|
|
ExplicitDeliverRoute: true,
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
const routeCall = firstRouteReplyCall() as
|
|
| { channel?: string; to?: string; threadId?: string | number }
|
|
| undefined;
|
|
expect(routeCall?.channel).toBe("mattermost");
|
|
expect(routeCall?.to).toBe("channel:CHAN1");
|
|
expect(routeCall?.threadId).toBeUndefined();
|
|
});
|
|
|
|
it("forces suppressTyping when routing to a different originating channel", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.suppressTyping).toBe(true);
|
|
expect(opts?.typingPolicy).toBe("system_event");
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
});
|
|
|
|
it("forces suppressTyping for internal webchat turns", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
OriginatingChannel: "webchat",
|
|
OriginatingTo: "session:abc",
|
|
});
|
|
|
|
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.suppressTyping).toBe(true);
|
|
expect(opts?.typingPolicy).toBe("internal_webchat");
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
});
|
|
|
|
it("routes when provider is webchat but surface carries originating channel metadata", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
const routeCall = firstRouteReplyCall() as { channel?: unknown; to?: unknown } | undefined;
|
|
expect(routeCall?.channel).toBe("telegram");
|
|
expect(routeCall?.to).toBe("telegram:999");
|
|
});
|
|
|
|
it("routes Feishu replies when provider is webchat and origin metadata points to Feishu", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "feishu",
|
|
OriginatingChannel: "feishu",
|
|
OriginatingTo: "ou_feishu_direct_123",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
const routeCall = firstRouteReplyCall() as { channel?: unknown; to?: unknown } | undefined;
|
|
expect(routeCall?.channel).toBe("feishu");
|
|
expect(routeCall?.to).toBe("ou_feishu_direct_123");
|
|
});
|
|
|
|
it("does not route when provider already matches originating channel", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "webchat",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
OriginatingChannel: "imessage",
|
|
OriginatingTo: "imessage:+15550001111",
|
|
});
|
|
|
|
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 external origin replies for internal webchat turns when explicit delivery is set", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
OriginatingChannel: "imessage",
|
|
OriginatingTo: "imessage:+15550001111",
|
|
ExplicitDeliverRoute: true,
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
const routeCall = firstRouteReplyCall() as
|
|
| { channel?: unknown; policyConversationType?: unknown; to?: unknown }
|
|
| undefined;
|
|
expect(routeCall?.channel).toBe("imessage");
|
|
expect(routeCall?.policyConversationType).toBe("direct");
|
|
expect(routeCall?.to).toBe("imessage:+15550001111");
|
|
});
|
|
|
|
it("routes media-only tool results when summaries are suppressed", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = automaticGroupReplyConfig;
|
|
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,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["https://example.com/tts-routed.opus"],
|
|
});
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
const normalizerOptions = replyMediaPathMocks.createReplyMediaPathNormalizer.mock
|
|
.calls[0]?.[0] as { cfg?: unknown; messageProvider?: unknown } | undefined;
|
|
expect(normalizerOptions?.cfg).toBe(cfg);
|
|
expect(normalizerOptions?.messageProvider).toBe("telegram");
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
|
|
const routed = firstRouteReplyCall() 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,
|
|
agents: { defaults: { verboseDefault: "on" } },
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "tool output" });
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "tool output" });
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not synthesize hidden text-only tool summaries into TTS media", async () => {
|
|
setNoAbort();
|
|
ttsMocks.state.synthesizeToolAudio = true;
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "tool output" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(ttsMocks.maybeApplyTtsToPayload).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ kind: "tool" }),
|
|
);
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("suppresses late text-only tool results after final delivery starts", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
ChatType: "channel",
|
|
IsForum: true,
|
|
SessionKey: "agent:main:discord:channel:C1",
|
|
});
|
|
let lateToolResult: NonNullable<GetReplyOptions["onToolResult"]> | undefined;
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
lateToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
await lateToolResult?.({ text: "failed command output", isError: true });
|
|
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses group tool summaries but still forwards tool media", async () => {
|
|
setNoAbort();
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "group",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "🔧 exec: ls" });
|
|
await onToolResult({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["https://example.com/tts-group.opus"],
|
|
});
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: false },
|
|
});
|
|
|
|
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("keeps group tool summaries suppressed when the channel omits the quiet-default flag", async () => {
|
|
setNoAbort();
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:group:123@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "🔧 exec: ls" });
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows group tool summaries when session verbose is enabled without a channel quiet-default flag", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "on",
|
|
};
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:group:123@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "🔧 exec: ls" });
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("🔧 exec: ls");
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows group tool summaries when the agent verbose default is enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...automaticGroupReplyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} as const satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "matrix",
|
|
Surface: "matrix",
|
|
ChatType: "group",
|
|
From: "matrix:group:!room:example.org",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "🔧 exec: pwd" });
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("🔧 exec: pwd");
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps group tool summaries suppressed when session verbose is disabled", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = {
|
|
...automaticGroupReplyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} as const satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:group:456@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:456@g.us",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "🔧 exec: date" });
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows group tool summaries when verbose is enabled during the run", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:group:789@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:789@g.us",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "on",
|
|
};
|
|
await onToolResult({ text: "🔧 exec: whoami" });
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("🔧 exec: whoami");
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps tool-error fallbacks available when verbose is disabled during the run", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "on",
|
|
};
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:group:789@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:789@g.us",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
receivedOptions = opts;
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
await onToolResult({ text: "🔧 exec: failed", isError: true });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(receivedOptions?.shouldSuppressToolErrorWarnings?.()).toBe(false);
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forwards channel-owned group progress callbacks while verbose is off", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "group",
|
|
From: "telegram:group:-100123",
|
|
SessionKey: "agent:main:telegram:group:-100123",
|
|
});
|
|
const onToolStart = vi.fn();
|
|
const onItemEvent = vi.fn();
|
|
const onPlanUpdate = vi.fn();
|
|
const onApprovalEvent = vi.fn();
|
|
const onCommandOutput = vi.fn();
|
|
const onPatchSummary = vi.fn();
|
|
const onCompactionStart = vi.fn();
|
|
const onCompactionEnd = vi.fn();
|
|
const onToolResult = vi.fn();
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolStart?.({ name: "exec", phase: "start" });
|
|
await opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running exec" });
|
|
await opts?.onPlanUpdate?.({ phase: "update", steps: ["Run command"] });
|
|
await opts?.onApprovalEvent?.({ phase: "requested", command: "pnpm test" });
|
|
await opts?.onCommandOutput?.({ phase: "end", name: "exec", status: "ok", exitCode: 0 });
|
|
await opts?.onPatchSummary?.({ phase: "end", summary: "1 modified" });
|
|
await opts?.onCompactionStart?.();
|
|
await opts?.onCompactionEnd?.();
|
|
await opts?.onToolResult?.({ text: "exec: ok" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
suppressDefaultToolProgressMessages: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onPlanUpdate,
|
|
onApprovalEvent,
|
|
onCommandOutput,
|
|
onPatchSummary,
|
|
onCompactionStart,
|
|
onCompactionEnd,
|
|
onToolResult,
|
|
},
|
|
});
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith({ name: "exec", phase: "start" });
|
|
expect(onItemEvent).toHaveBeenCalledWith({
|
|
itemId: "1",
|
|
kind: "tool",
|
|
progressText: "running exec",
|
|
});
|
|
expect(onPlanUpdate).toHaveBeenCalledWith({ phase: "update", steps: ["Run command"] });
|
|
expect(onApprovalEvent).toHaveBeenCalledWith({
|
|
phase: "requested",
|
|
command: "pnpm test",
|
|
});
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "ok",
|
|
exitCode: 0,
|
|
});
|
|
expect(onPatchSummary).toHaveBeenCalledWith({ phase: "end", summary: "1 modified" });
|
|
expect(onCompactionStart).toHaveBeenCalledTimes(1);
|
|
expect(onCompactionEnd).toHaveBeenCalledTimes(1);
|
|
expect(onToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forwards channel-owned group progress callbacks while source delivery is suppressed", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "group",
|
|
From: "telegram:group:-100123",
|
|
SessionKey: "agent:main:telegram:group:-100123",
|
|
});
|
|
const onToolStart = vi.fn();
|
|
const onItemEvent = vi.fn();
|
|
const onCommandOutput = vi.fn();
|
|
const onToolResult = vi.fn();
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolStart?.({ name: "exec", phase: "start" });
|
|
await opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running exec" });
|
|
await opts?.onCommandOutput?.({ phase: "end", name: "exec", status: "ok", exitCode: 0 });
|
|
await opts?.onToolResult?.({ text: "exec: ok" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
suppressDefaultToolProgressMessages: true,
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onCommandOutput,
|
|
onToolResult,
|
|
},
|
|
});
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith({ name: "exec", phase: "start" });
|
|
expect(onItemEvent).toHaveBeenCalledWith({
|
|
itemId: "1",
|
|
kind: "tool",
|
|
progressText: "running exec",
|
|
});
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "ok",
|
|
exitCode: 0,
|
|
});
|
|
expect(onToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses channel-owned room-event progress callbacks while source delivery is suppressed", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "group",
|
|
InboundEventKind: "room_event",
|
|
From: "telegram:group:-100123",
|
|
SessionKey: "agent:main:telegram:group:-100123",
|
|
});
|
|
const onToolStart = vi.fn();
|
|
const onItemEvent = vi.fn();
|
|
const onCommandOutput = vi.fn();
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolStart?.({ name: "exec", phase: "start" });
|
|
await opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running exec" });
|
|
await opts?.onCommandOutput?.({ phase: "end", name: "exec", status: "ok", exitCode: 0 });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
suppressDefaultToolProgressMessages: true,
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onCommandOutput,
|
|
},
|
|
});
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
expect(onItemEvent).not.toHaveBeenCalled();
|
|
expect(onCommandOutput).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exposes live tool-summary state to reply_dispatch hooks", () => {
|
|
let shouldSendToolSummaries = false;
|
|
const event = dispatchFromConfigTesting.createReplyDispatchEvent({
|
|
shouldSendToolSummaries: () => shouldSendToolSummaries,
|
|
} as never) as { shouldSendToolSummaries: boolean };
|
|
|
|
expect(event.shouldSendToolSummaries).toBe(false);
|
|
shouldSendToolSummaries = true;
|
|
expect(event.shouldSendToolSummaries).toBe(true);
|
|
});
|
|
|
|
it("forwards direct native progress callbacks while verbose is off", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "direct",
|
|
From: "telegram:123",
|
|
SessionKey: "agent:main:telegram:dm:123",
|
|
});
|
|
const onToolStart = vi.fn();
|
|
const onItemEvent = vi.fn();
|
|
const onCommandOutput = vi.fn();
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolStart?.({ name: "exec", phase: "start" });
|
|
await opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running exec" });
|
|
await opts?.onCommandOutput?.({ phase: "end", name: "exec", status: "ok", exitCode: 0 });
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ok" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
suppressDefaultToolProgressMessages: true,
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onCommandOutput,
|
|
},
|
|
});
|
|
|
|
expect(onToolStart).toHaveBeenCalledWith({ name: "exec", phase: "start" });
|
|
expect(onItemEvent).toHaveBeenCalledWith({
|
|
itemId: "1",
|
|
kind: "tool",
|
|
progressText: "running exec",
|
|
});
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "ok",
|
|
exitCode: 0,
|
|
});
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses direct native progress callbacks when send policy denies delivery", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sendPolicy: "deny",
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "direct",
|
|
From: "telegram:123",
|
|
SessionKey: "agent:main:telegram:dm:123",
|
|
});
|
|
const onToolStart = vi.fn();
|
|
const onItemEvent = vi.fn();
|
|
const onCommandOutput = vi.fn();
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolStart?.({ name: "exec", phase: "start" });
|
|
await opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running exec" });
|
|
await opts?.onCommandOutput?.({ phase: "end", name: "exec", status: "ok", exitCode: 0 });
|
|
await opts?.onToolResult?.({ text: "exec: ok" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
suppressDefaultToolProgressMessages: true,
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onToolStart,
|
|
onItemEvent,
|
|
onCommandOutput,
|
|
},
|
|
});
|
|
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
expect(onItemEvent).not.toHaveBeenCalled();
|
|
expect(onCommandOutput).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("normalizes tool-result media before delivery and drops blocked file URLs", async () => {
|
|
setNoAbort();
|
|
replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue(
|
|
async (payload: ReplyPayload) => ({
|
|
...payload,
|
|
mediaUrl: undefined,
|
|
mediaUrls: undefined,
|
|
}),
|
|
);
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
ChatType: "group",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({
|
|
text: "NO_REPLY",
|
|
mediaUrls: ["file://attacker/share/probe.mp3"],
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
const normalizerOptions = replyMediaPathMocks.createReplyMediaPathNormalizer.mock
|
|
.calls[0]?.[0] as { cfg?: unknown; messageProvider?: unknown } | undefined;
|
|
expect(normalizerOptions?.cfg).toBe(cfg);
|
|
expect(normalizerOptions?.messageProvider).toBe("webchat");
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("delivers tool summaries in forum topic sessions when verbose is enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...automaticGroupReplyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "group",
|
|
IsForum: true,
|
|
MessageThreadId: 99,
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("🔧 exec: ls");
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("delivers deterministic exec approval tool payloads in groups", async () => {
|
|
setNoAbort();
|
|
const cfg = automaticGroupReplyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "group",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({
|
|
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
|
approvalSlug: "117ba06d",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "NO_REPLY" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
const toolPayload = firstToolResultPayload(dispatcher);
|
|
expect(toolPayload?.text).toBe(
|
|
"Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
|
);
|
|
expect(toolPayload?.channelData).toStrictEqual({
|
|
execApproval: {
|
|
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
|
approvalSlug: "117ba06d",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
});
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
|
|
});
|
|
|
|
it("sends tool results via dispatcher in DM sessions", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: { defaults: { verboseDefault: "on" } },
|
|
} satisfies OpenClawConfig;
|
|
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(firstToolResultPayload(dispatcher)?.text).toBe("🔧 exec: ls");
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("delivers native tool summaries and tool media", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: { defaults: { verboseDefault: "on" } },
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
CommandSource: "native",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
const onToolResult = requireToolResultHandler(opts?.onToolResult);
|
|
await onToolResult({ text: "🔧 tools/sessions_send" });
|
|
await onToolResult({
|
|
mediaUrl: "https://example.com/tts-native.opus",
|
|
});
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(2);
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("🔧 tools/sessions_send");
|
|
const sent = firstMockArg(
|
|
dispatcher.sendToolResult as ReturnType<typeof vi.fn>,
|
|
"tool result",
|
|
1,
|
|
) as ReplyPayload;
|
|
expect(sent.mediaUrl).toBe("https://example.com/tts-native.opus");
|
|
expect(sent.text).toBeUndefined();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("bypasses final TTS for status notices", async () => {
|
|
setNoAbort();
|
|
ttsMocks.state.synthesizeFinalAudio = true;
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
const notice = {
|
|
text: "Model Fallback: openai/gpt-5.5",
|
|
isFallbackNotice: true,
|
|
} satisfies ReplyPayload;
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver: async () => notice,
|
|
});
|
|
|
|
expect(ttsMocks.maybeApplyTtsToPayload).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(notice);
|
|
});
|
|
|
|
it("renders the first plan update as a status notice without generic working statuses", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
explanation: "Inspect code, patch it, run tests.",
|
|
steps: ["Inspect code", "Patch code", "Run tests"],
|
|
});
|
|
await opts?.onApprovalEvent?.({
|
|
phase: "requested",
|
|
status: "pending",
|
|
command: "pnpm test",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(firstToolResultPayload(dispatcher)).toMatchObject({
|
|
text: "1. Inspect code\n2. Patch code\n3. Run tests",
|
|
isStatusNotice: true,
|
|
});
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("sends only one plan status notice per reply run", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: { defaults: { verboseDefault: "on" } },
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
steps: ["Inspect code"],
|
|
});
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
steps: ["Inspect code", "Patch code"],
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(firstToolResultPayload(dispatcher)).toMatchObject({
|
|
text: "1. Inspect code",
|
|
isStatusNotice: true,
|
|
});
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("suppresses generic patch working statuses when verbose is enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onPatchSummary?.({
|
|
phase: "end",
|
|
title: "apply patch",
|
|
summary: "1 added, 2 modified",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("delivers Slack non-DM verbose progress when verbose is enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
...emptyConfig,
|
|
messages: automaticGroupReplyConfig.messages,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
ChatType: "channel",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
explanation: "Inspect code, patch it, run tests.",
|
|
steps: ["Inspect code", "Patch code", "Run tests"],
|
|
});
|
|
await opts?.onPatchSummary?.({
|
|
phase: "end",
|
|
title: "apply patch",
|
|
summary: "1 added, 2 modified",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("suppresses plan notices when session verbose is off", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
explanation: "Inspect code, patch it, run tests.",
|
|
steps: ["Inspect code", "Patch code", "Run tests"],
|
|
});
|
|
await opts?.onApprovalEvent?.({
|
|
phase: "requested",
|
|
status: "pending",
|
|
command: "pnpm test",
|
|
});
|
|
await opts?.onPatchSummary?.({
|
|
phase: "end",
|
|
title: "apply patch",
|
|
summary: "1 added, 2 modified",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("refreshes verbose progress with session entry snapshots", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "off",
|
|
};
|
|
sessionStoreMocks.readSessionEntry.mockReturnValue({ verboseLevel: "on" });
|
|
const cfg = {
|
|
...emptyConfig,
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
sessionStoreMocks.loadSessionStore.mockClear();
|
|
sessionStoreMocks.resolveSessionStoreEntry.mockClear();
|
|
sessionStoreMocks.readSessionEntry.mockClear();
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
explanation: "Inspect code, patch it, run tests.",
|
|
steps: ["Inspect code", "Patch code", "Run tests"],
|
|
});
|
|
await opts?.onApprovalEvent?.({
|
|
phase: "requested",
|
|
status: "pending",
|
|
command: "pnpm test",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(sessionStoreMocks.readSessionEntry).toHaveBeenCalledWith(
|
|
"/tmp/mock-sessions.json",
|
|
"agent:main:main",
|
|
);
|
|
expect(sessionStoreMocks.loadSessionStore).not.toHaveBeenCalled();
|
|
expect(sessionStoreMocks.resolveSessionStoreEntry).not.toHaveBeenCalled();
|
|
expect(firstToolResultPayload(dispatcher)).toMatchObject({
|
|
text: "1. Inspect code\n2. Patch code\n3. Run tests",
|
|
isStatusNotice: true,
|
|
});
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("suppresses text-only tool summaries when preview tool-progress suppression is enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("keeps failed tools compact when preview tool-progress suppression is enabled", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const onCommandOutput = vi.fn();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
ChatType: "channel",
|
|
IsForum: true,
|
|
SessionKey: "agent:main:discord:channel:C1",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onCommandOutput?.({
|
|
phase: "end",
|
|
title: "Exec",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
});
|
|
await opts?.onToolResult?.({ text: "raw failed command output", isError: true });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
suppressDefaultToolProgressMessages: true,
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onCommandOutput,
|
|
},
|
|
});
|
|
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
phase: "end",
|
|
title: "Exec",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
});
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps message-tool-only failed tool output compact in normal verbose mode", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const onCommandOutput = vi.fn();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onCommandOutput?.({
|
|
phase: "end",
|
|
title: "Exec",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 2,
|
|
});
|
|
await opts?.onToolResult?.({
|
|
text: "🛠️ Bash: `ls /tmp/missing`\n```txt\nNo such file or directory\n```",
|
|
isError: true,
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onCommandOutput,
|
|
},
|
|
});
|
|
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
phase: "end",
|
|
title: "Exec",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 2,
|
|
});
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps terminal tool-error fallbacks available when message-tool-only error text is hidden", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
|
|
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
receivedOptions = opts;
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
await opts?.onToolResult?.({
|
|
text: "🛠️ Bash: `ls /tmp/missing`\n```txt\nNo such file or directory\n```",
|
|
isError: true,
|
|
});
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(receivedOptions?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows message-tool-only failed tool output in verbose full mode", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "full",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
const failedOutput = {
|
|
text: "🛠️ Bash: `ls /tmp/missing`\n```txt\nNo such file or directory\n```",
|
|
isError: true,
|
|
} satisfies ReplyPayload;
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.(failedOutput);
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith(failedOutput);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses terminal tool-error fallbacks when regular verbose progress is visible", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const onCommandOutput = vi.fn();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
receivedOptions = opts;
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
await opts?.onCommandOutput?.({
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { onCommandOutput },
|
|
});
|
|
|
|
expect(onCommandOutput).toHaveBeenCalledWith({
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
});
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(receivedOptions?.shouldSuppressToolErrorWarnings?.()).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("suppresses terminal tool-error fallbacks in group sessions when verbose progress is visible", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const onItemEvent = vi.fn();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:group:123@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
receivedOptions = opts;
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
await opts?.onItemEvent?.({
|
|
itemId: "item-1",
|
|
kind: "tool",
|
|
name: "exec",
|
|
status: "failed",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: automaticGroupReplyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { onItemEvent },
|
|
});
|
|
|
|
expect(onItemEvent).toHaveBeenCalledWith({
|
|
itemId: "item-1",
|
|
kind: "tool",
|
|
name: "exec",
|
|
status: "failed",
|
|
});
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(receivedOptions?.shouldSuppressToolErrorWarnings?.()).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("keeps terminal tool-error fallbacks available when verbose turns on after a quiet failure", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "off",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const onCommandOutput = vi.fn();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
receivedOptions = opts;
|
|
await opts?.onCommandOutput?.({
|
|
phase: "end",
|
|
name: "exec",
|
|
status: "failed",
|
|
exitCode: 1,
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
...sessionStoreMocks.currentEntry,
|
|
verboseLevel: "on",
|
|
};
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
onCommandOutput,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(onCommandOutput).not.toHaveBeenCalled();
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(receivedOptions?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not pre-latch terminal tool-error suppression when diagnostics are disabled", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
receivedOptions = opts;
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBeUndefined();
|
|
sessionStoreMocks.currentEntry = {
|
|
...sessionStoreMocks.currentEntry,
|
|
verboseLevel: "off",
|
|
};
|
|
expect(opts?.shouldSuppressToolErrorWarnings?.()).toBe(false);
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: { diagnostics: { enabled: false } } as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(receivedOptions?.shouldSuppressToolErrorWarnings?.()).toBe(false);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("keeps terminal tool-error fallbacks available in verbose full mode", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "full",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
});
|
|
let receivedOptions: GetReplyOptions | undefined;
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
receivedOptions = opts;
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(receivedOptions?.suppressToolErrorWarnings).toBeUndefined();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("delivers text-only tool summaries when verbose overrides preview suppression", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "on",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "🔧 exec: ls" });
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("delivers plan status when verbose overrides preview suppression", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
verboseLevel: "on",
|
|
};
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onPlanUpdate?.({
|
|
phase: "update",
|
|
explanation: "Inspect code.",
|
|
steps: ["Patch code"],
|
|
});
|
|
await opts?.onApprovalEvent?.({
|
|
phase: "requested",
|
|
status: "pending",
|
|
command: "pnpm test",
|
|
});
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenNthCalledWith(1, {
|
|
text: "1. Patch code",
|
|
isStatusNotice: true,
|
|
});
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("delivers verbose tool summaries despite message-tool-only source suppression", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
await opts?.onToolResult?.({ text: "🛠️ `pwd (agent)`" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "🛠️ `pwd (agent)`" });
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps verbose tool summaries suppressed for room events", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
ChatType: "channel",
|
|
InboundEventKind: "room_event",
|
|
SessionKey: "agent:main:discord:channel:C1",
|
|
});
|
|
|
|
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
await opts?.onToolResult?.({ text: "🛠️ `pwd (agent)`" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers verbose tool summaries for Discord channel message-tool-only turns", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
ChatType: "channel",
|
|
IsForum: true,
|
|
SessionKey: "agent:main:discord:channel:C1",
|
|
});
|
|
|
|
const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
await opts?.onToolResult?.({ text: "🛠️ `pwd (agent)`" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith({ text: "🛠️ `pwd (agent)`" });
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still delivers media-only tool payloads when preview tool-progress suppression is enabled", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
ChatType: "direct",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({ mediaUrl: "https://example.com/tts-preview.opus" });
|
|
return { text: "done" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(firstToolResultPayload(dispatcher)?.mediaUrl).toBe(
|
|
"https://example.com/tts-preview.opus",
|
|
);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
|
});
|
|
|
|
it("delivers deterministic exec approval tool payloads for native commands with progress suppression", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
CommandSource: "native",
|
|
});
|
|
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
_cfg?: OpenClawConfig,
|
|
) => {
|
|
await opts?.onToolResult?.({
|
|
text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
|
approvalSlug: "117ba06d",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "NO_REPLY" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { suppressDefaultToolProgressMessages: true },
|
|
});
|
|
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
|
expect(firstToolResultPayload(dispatcher)?.channelData).toStrictEqual({
|
|
execApproval: {
|
|
approvalId: "117ba06d-1111-2222-3333-444444444444",
|
|
approvalSlug: "117ba06d",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
});
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" });
|
|
});
|
|
|
|
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" },
|
|
]);
|
|
let currentAcpEntry = {
|
|
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.readAcpSessionEntry.mockImplementation(() => currentAcpEntry);
|
|
acpMocks.upsertAcpSessionMeta.mockImplementation(async (paramsUnknown: unknown) => {
|
|
const params = paramsUnknown as {
|
|
mutate: (
|
|
current: Record<string, unknown> | undefined,
|
|
entry: { acp?: Record<string, unknown> } | undefined,
|
|
) => Record<string, unknown> | null | undefined;
|
|
};
|
|
const nextMeta = params.mutate(currentAcpEntry.acp as Record<string, unknown>, {
|
|
acp: currentAcpEntry.acp as Record<string, unknown>,
|
|
});
|
|
if (nextMeta === null) {
|
|
return null;
|
|
}
|
|
if (nextMeta) {
|
|
currentAcpEntry = {
|
|
...currentAcpEntry,
|
|
acp: nextMeta as typeof currentAcpEntry.acp,
|
|
};
|
|
}
|
|
return currentAcpEntry;
|
|
});
|
|
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
|
|
id: "acpx",
|
|
runtime,
|
|
});
|
|
|
|
const cfg = {
|
|
acp: {
|
|
enabled: true,
|
|
dispatch: { enabled: true },
|
|
stream: { deliveryMode: "live", 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();
|
|
const ensureSessionOptions = firstMockArg(runtime.ensureSession, "ensure session") as
|
|
| { agent?: unknown; mode?: unknown; sessionKey?: unknown }
|
|
| undefined;
|
|
expect(ensureSessionOptions?.sessionKey).toBe("agent:codex-acp:session-1");
|
|
expect(ensureSessionOptions?.agent).toBe("codex");
|
|
expect(ensureSessionOptions?.mode).toBe("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");
|
|
const finalPayload = firstFinalReplyPayload(dispatcher);
|
|
expect(finalPayload?.text).toBe("hello world");
|
|
});
|
|
|
|
it("emits lifecycle end for ACP turns using the current run id", async () => {
|
|
setNoAbort();
|
|
const runtime = createAcpRuntime([{ type: "text_delta", text: "done" }, { 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 dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
SessionKey: "agent:codex-acp:session-1",
|
|
BodyForAgent: "write a test",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: {
|
|
acp: {
|
|
enabled: true,
|
|
dispatch: { enabled: true },
|
|
stream: { coalesceIdleMs: 0, maxChunkChars: 128 },
|
|
},
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyOptions: {
|
|
runId: "run-acp-lifecycle-end",
|
|
},
|
|
});
|
|
|
|
const lifecycleEvent = agentEventMocks.emitAgentEvent.mock.calls
|
|
.map(
|
|
(call) =>
|
|
call[0] as {
|
|
data?: { phase?: unknown };
|
|
runId?: unknown;
|
|
sessionKey?: unknown;
|
|
stream?: unknown;
|
|
},
|
|
)
|
|
.find((event) => event.runId === "run-acp-lifecycle-end");
|
|
expect(lifecycleEvent?.sessionKey).toBe("agent:codex-acp:session-1");
|
|
expect(lifecycleEvent?.stream).toBe("lifecycle");
|
|
expect(lifecycleEvent?.data?.phase).toBe("end");
|
|
});
|
|
|
|
it("emits lifecycle error for ACP turn failures using the current run id", async () => {
|
|
setNoAbort();
|
|
const runtime = createAcpRuntime([]);
|
|
runtime.runTurn.mockImplementation(async function* () {
|
|
yield { type: "status", tag: "usage_update", text: "warming up" };
|
|
throw new Error("ACP exploded");
|
|
});
|
|
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 dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
SessionKey: "agent:codex-acp:session-1",
|
|
BodyForAgent: "write a test",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: {
|
|
acp: {
|
|
enabled: true,
|
|
dispatch: { enabled: true },
|
|
stream: { coalesceIdleMs: 0, maxChunkChars: 128 },
|
|
},
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyOptions: {
|
|
runId: "run-acp-lifecycle-error",
|
|
},
|
|
});
|
|
|
|
const lifecycleEvent = agentEventMocks.emitAgentEvent.mock.calls
|
|
.map(
|
|
(call) =>
|
|
call[0] as {
|
|
data?: { error?: unknown; phase?: unknown };
|
|
runId?: unknown;
|
|
sessionKey?: unknown;
|
|
stream?: unknown;
|
|
},
|
|
)
|
|
.find((event) => event.runId === "run-acp-lifecycle-error");
|
|
expect(lifecycleEvent?.sessionKey).toBe("agent:codex-acp:session-1");
|
|
expect(lifecycleEvent?.stream).toBe("lifecycle");
|
|
expect(lifecycleEvent?.data?.phase).toBe("error");
|
|
expect(String(lifecycleEvent?.data?.error)).toContain("ACP exploded");
|
|
});
|
|
|
|
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(2);
|
|
const noticePayload = finalCalls[1]?.[0] as ReplyPayload | undefined;
|
|
expect(noticePayload?.text).toContain("Session ids resolved");
|
|
expect(noticePayload?.text).toContain("agent session id: inner-123");
|
|
expect(noticePayload?.text).toContain("acpx session id: acpx-123");
|
|
expect(noticePayload?.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(2);
|
|
const noticePayload = finalCalls[1]?.[0] as ReplyPayload | undefined;
|
|
expect(noticePayload?.text).toContain("Session ids resolved");
|
|
expect(noticePayload?.text).toContain("agent session id: inner-123");
|
|
expect(noticePayload?.text).toContain("acpx session id: acpx-123");
|
|
});
|
|
|
|
it("honors the configured default account when resolving plugin-owned binding fallbacks", async () => {
|
|
setNoAbort();
|
|
sessionBindingMocks.resolveByConversation.mockImplementation(
|
|
(ref: {
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
parentConversationId?: string;
|
|
}) =>
|
|
ref.channel === "discord" && ref.accountId === "work" && ref.conversationId === "thread-1"
|
|
? ({
|
|
bindingId: "plugin:work:thread-1",
|
|
targetSessionKey: "plugin-binding:missing-plugin",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "work",
|
|
conversationId: "thread-1",
|
|
},
|
|
status: "active",
|
|
boundAt: Date.now(),
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "missing-plugin",
|
|
pluginRoot: "/plugins/missing-plugin",
|
|
pluginName: "Missing Plugin",
|
|
},
|
|
} satisfies SessionBindingRecord)
|
|
: null,
|
|
);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
defaultAccount: "work",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => undefined);
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
To: "discord:thread-1",
|
|
SessionKey: "main",
|
|
BodyForAgent: "fallback",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
const bindingLookup = firstMockArg(
|
|
sessionBindingMocks.resolveByConversation,
|
|
"conversation binding lookup",
|
|
) as { accountId?: unknown; channel?: unknown; conversationId?: unknown } | undefined;
|
|
expect(bindingLookup?.channel).toBe("discord");
|
|
expect(bindingLookup?.accountId).toBe("work");
|
|
expect(bindingLookup?.conversationId).toBe("thread-1");
|
|
expect(firstToolResultPayload(dispatcher)?.text).toContain("not currently loaded");
|
|
expect(replyResolver).toHaveBeenCalled();
|
|
});
|
|
|
|
it("retargets reply_dispatch to a bound generic ACP session before model fallback", async () => {
|
|
setNoAbort();
|
|
const boundSessionKey = "agent:opencode:acp:bound-session";
|
|
const runtime = createAcpRuntime([
|
|
{ type: "text_delta", text: "Bound ACP reply" },
|
|
{ type: "done" },
|
|
]);
|
|
acpMocks.readAcpSessionEntry.mockImplementation(
|
|
(params: { sessionKey: string; cfg?: OpenClawConfig }) =>
|
|
params.sessionKey === boundSessionKey
|
|
? {
|
|
sessionKey: boundSessionKey,
|
|
storeSessionKey: boundSessionKey,
|
|
cfg: {},
|
|
storePath: "/tmp/mock-sessions.json",
|
|
entry: {},
|
|
acp: {
|
|
backend: "acpx",
|
|
agent: "opencode",
|
|
runtimeSessionName: "runtime:opencode",
|
|
mode: "persistent",
|
|
state: "idle",
|
|
lastActivityAt: Date.now(),
|
|
},
|
|
}
|
|
: null,
|
|
);
|
|
acpMocks.requireAcpRuntimeBackend.mockReturnValue({
|
|
id: "acpx",
|
|
runtime,
|
|
});
|
|
const boundConversationBinding = {
|
|
bindingId: "binding-acp-current",
|
|
targetSessionKey: boundSessionKey,
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "C123",
|
|
},
|
|
status: "active",
|
|
boundAt: Date.now(),
|
|
} satisfies SessionBindingRecord;
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue(boundConversationBinding);
|
|
sessionBindingMocks.listBySession.mockImplementation((targetSessionKey: string) =>
|
|
targetSessionKey === boundSessionKey ? [boundConversationBinding] : [],
|
|
);
|
|
|
|
const cfg = {
|
|
acp: {
|
|
enabled: true,
|
|
dispatch: { enabled: true },
|
|
stream: { deliveryMode: "live", coalesceIdleMs: 0, maxChunkChars: 256 },
|
|
},
|
|
} as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "fallback reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:C123",
|
|
To: "discord:C123",
|
|
AccountId: "default",
|
|
SessionKey: "agent:main:discord:C123",
|
|
BodyForAgent: "continue",
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(sessionBindingMocks.resolveByConversation).toHaveBeenCalledWith({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "C123",
|
|
});
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-acp-current");
|
|
const ensureSessionOptions = firstMockArg(runtime.ensureSession, "ensure session") as
|
|
| { agent?: unknown; sessionKey?: unknown }
|
|
| undefined;
|
|
expect(ensureSessionOptions?.sessionKey).toBe(boundSessionKey);
|
|
expect(ensureSessionOptions?.agent).toBe("opencode");
|
|
const runTurnOptions = firstMockArg(runtime.runTurn, "run turn") as
|
|
| { text?: unknown }
|
|
| undefined;
|
|
expect(runTurnOptions?.text).toBe("continue");
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
const blockPayload = firstMockArg(
|
|
dispatcher.sendBlockReply as ReturnType<typeof vi.fn>,
|
|
"block reply",
|
|
) as ReplyPayload | undefined;
|
|
expect(blockPayload?.text).toBe("Bound ACP reply");
|
|
});
|
|
|
|
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: { deliveryMode: "live", 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: string[] = [];
|
|
for (const call of (dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mock.calls) {
|
|
const text = ((call[0] as ReplyPayload).text ?? "").trim();
|
|
if (text.length > 0) {
|
|
blockTexts.push(text);
|
|
}
|
|
}
|
|
expect(blockTexts).toEqual(["What do you want to work on?"]);
|
|
const finalPayload = firstFinalReplyPayload(dispatcher);
|
|
expect(finalPayload?.text).toBe("What do you want to work on?");
|
|
});
|
|
|
|
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: { deliveryMode: "live", 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("normalizes accumulated block TTS-only media before final delivery", async () => {
|
|
setNoAbort();
|
|
ttsMocks.state.synthesizeFinalAudio = true;
|
|
replyMediaPathMocks.createReplyMediaPathNormalizer.mockReturnValue(
|
|
async (payload: ReplyPayload) => ({
|
|
...payload,
|
|
mediaUrl: "/tmp/openclaw-media/normalized-tts.ogg",
|
|
mediaUrls: ["/tmp/openclaw-media/normalized-tts.ogg"],
|
|
}),
|
|
);
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "feishu",
|
|
Surface: "feishu",
|
|
SessionKey: "agent:main:feishu:ou_user",
|
|
});
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "Hello from block streaming." });
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
|
|
|
|
const normalizerOptions = replyMediaPathMocks.createReplyMediaPathNormalizer.mock
|
|
.calls[0]?.[0] as { messageProvider?: unknown } | undefined;
|
|
expect(normalizerOptions?.messageProvider).toBe("feishu");
|
|
const finalPayload = firstFinalReplyPayload(dispatcher);
|
|
expect(finalPayload?.mediaUrl).toBe("/tmp/openclaw-media/normalized-tts.ogg");
|
|
expect(finalPayload?.mediaUrls).toStrictEqual(["/tmp/openclaw-media/normalized-tts.ogg"]);
|
|
expect(finalPayload?.audioAsVoice).toBe(true);
|
|
expect(finalPayload?.spokenText).toBe("Hello from block streaming.");
|
|
expect(finalPayload?.trustedLocalMedia).toBe(true);
|
|
});
|
|
|
|
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 });
|
|
|
|
const closeOptions = firstMockArg(runtime.close, "runtime close") as
|
|
| { reason?: unknown }
|
|
| undefined;
|
|
expect(closeOptions?.reason).toBe("oneshot-complete");
|
|
});
|
|
|
|
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("keeps message-tool-only delivery mode on duplicate inbound returns", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
messages: {
|
|
groupChat: { visibleReplies: "message_tool" },
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "channel",
|
|
To: "telegram:chat:123",
|
|
MessageSid: "msg-tool-only-duplicate",
|
|
SessionKey: "agent:main:telegram:channel:123",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
|
|
const [first, duplicate] = await dispatchTwiceWithFreshDispatchers({
|
|
ctx,
|
|
cfg,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(first.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(duplicate.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
});
|
|
|
|
it("does not mark duplicate inbound returns as tool-only when message is unavailable", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
messages: {
|
|
groupChat: { visibleReplies: "message_tool" },
|
|
},
|
|
tools: { allow: ["read"] },
|
|
} as OpenClawConfig;
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
ChatType: "channel",
|
|
To: "telegram:chat:123",
|
|
MessageSid: "msg-tool-unavailable-duplicate",
|
|
SessionKey: "agent:main:telegram:channel:123",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "visible fallback" }) as ReplyPayload);
|
|
|
|
const [first, duplicate] = await dispatchTwiceWithFreshDispatchers({
|
|
ctx,
|
|
cfg,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(first.sourceReplyDeliveryMode).toBeUndefined();
|
|
expect(duplicate.sourceReplyDeliveryMode).toBeUndefined();
|
|
});
|
|
|
|
it("keeps local discord exec approval tool prompts when the native runtime is inactive", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
enabled: true,
|
|
execApprovals: {
|
|
enabled: true,
|
|
approvers: ["123"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
AccountId: "default",
|
|
});
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
|
await options?.onToolResult?.({
|
|
text: "Approval required.",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "12345678-1234-1234-1234-123456789012",
|
|
approvalSlug: "12345678",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "done" } as ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("Approval required.");
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
|
|
});
|
|
|
|
it("suppresses local discord exec approval tool prompts when the native runtime is active", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
enabled: true,
|
|
execApprovals: {
|
|
enabled: true,
|
|
approvers: ["123"],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const reporter = createApprovalNativeRouteReporter({
|
|
handledKinds: new Set(["exec"]),
|
|
channel: "discord",
|
|
channelLabel: "Discord",
|
|
accountId: "default",
|
|
requestGateway: async <T>() => ({ ok: true }) as T,
|
|
});
|
|
reporter.start();
|
|
try {
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
AccountId: "default",
|
|
});
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
|
await options?.onToolResult?.({
|
|
text: "Approval required.",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "12345678-1234-1234-1234-123456789012",
|
|
approvalSlug: "12345678",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "done" } as ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
|
|
} finally {
|
|
await reporter.stop();
|
|
}
|
|
});
|
|
|
|
it("keeps local signal exec approval tool prompts when the native runtime is inactive", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
channels: {
|
|
signal: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "signal",
|
|
Surface: "signal",
|
|
AccountId: "default",
|
|
SessionKey: "agent:main:signal:+15551230000",
|
|
});
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
|
await options?.onToolResult?.({
|
|
text: "Approval required.",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "12345678-1234-1234-1234-123456789012",
|
|
approvalSlug: "12345678",
|
|
approvalKind: "exec",
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "done" } as ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("Approval required.");
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
|
|
});
|
|
|
|
it("suppresses local signal exec approval tool prompts when the native runtime is active", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
channels: {
|
|
signal: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
approvals: {
|
|
exec: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const reporter = createApprovalNativeRouteReporter({
|
|
handledKinds: new Set(["exec"]),
|
|
channel: "signal",
|
|
channelLabel: "Signal",
|
|
accountId: "default",
|
|
requestGateway: async <T>() => ({ ok: true }) as T,
|
|
});
|
|
reporter.start();
|
|
try {
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "signal",
|
|
Surface: "signal",
|
|
AccountId: "default",
|
|
SessionKey: "agent:main:signal:+15551230000",
|
|
});
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
|
await options?.onToolResult?.({
|
|
text: "Approval required.",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "12345678-1234-1234-1234-123456789012",
|
|
approvalSlug: "12345678",
|
|
approvalKind: "exec",
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "done" } as ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
|
|
} finally {
|
|
await reporter.stop();
|
|
}
|
|
});
|
|
|
|
it("keeps local signal exec approval tool prompts when top-level exec approvals are disabled", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
channels: {
|
|
signal: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
approvals: {
|
|
exec: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const reporter = createApprovalNativeRouteReporter({
|
|
handledKinds: new Set(["exec"]),
|
|
channel: "signal",
|
|
channelLabel: "Signal",
|
|
accountId: "default",
|
|
requestGateway: async <T>() => ({ ok: true }) as T,
|
|
});
|
|
reporter.start();
|
|
try {
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "signal",
|
|
Surface: "signal",
|
|
AccountId: "default",
|
|
SessionKey: "agent:main:signal:+15551230000",
|
|
});
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => {
|
|
await options?.onToolResult?.({
|
|
text: "Approval required.",
|
|
channelData: {
|
|
execApproval: {
|
|
approvalId: "12345678-1234-1234-1234-123456789012",
|
|
approvalSlug: "12345678",
|
|
approvalKind: "exec",
|
|
sessionKey: "agent:main:signal:+15551230000",
|
|
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
|
},
|
|
},
|
|
});
|
|
return { text: "done" } as ReplyPayload;
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(firstToolResultPayload(dispatcher)?.text).toBe("Approval required.");
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("done");
|
|
} finally {
|
|
await reporter.stop();
|
|
}
|
|
});
|
|
|
|
it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
|
const baseCtx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:7463849194",
|
|
MessageSid: "msg-1",
|
|
SessionKey: "agent:main:main",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx: baseCtx,
|
|
cfg,
|
|
dispatcher: createDispatcher(),
|
|
replyResolver,
|
|
});
|
|
await dispatchReplyFromConfig({
|
|
ctx: {
|
|
...baseCtx,
|
|
SessionKey: "agent:main:telegram:direct:7463849194",
|
|
},
|
|
cfg,
|
|
dispatcher: createDispatcher(),
|
|
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 });
|
|
|
|
const [event, hookContext] = firstMockCall(
|
|
hookMocks.runner.runMessageReceived,
|
|
"message received hook",
|
|
) as
|
|
| [
|
|
{
|
|
content?: unknown;
|
|
from?: unknown;
|
|
metadata?: Record<string, unknown>;
|
|
timestamp?: unknown;
|
|
},
|
|
{ accountId?: unknown; channelId?: unknown; conversationId?: unknown },
|
|
]
|
|
| [];
|
|
expect(event?.from).toBe(ctx.From);
|
|
expect(event?.content).toBe("/search hello");
|
|
expect(event?.timestamp).toBe(1710000000000);
|
|
expect(event?.metadata?.originatingChannel).toBe("Telegram");
|
|
expect(event?.metadata?.originatingTo).toBe("telegram:999");
|
|
expect(event?.metadata?.messageId).toBe("sid-full");
|
|
expect(event?.metadata?.senderId).toBe("user-1");
|
|
expect(event?.metadata?.senderName).toBe("Alice");
|
|
expect(event?.metadata?.senderUsername).toBe("alice");
|
|
expect(event?.metadata?.senderE164).toBe("+15555550123");
|
|
expect(event?.metadata?.guildId).toBe("guild-123");
|
|
expect(event?.metadata?.channelName).toBe("alerts");
|
|
expect(hookContext?.channelId).toBe("telegram");
|
|
expect(hookContext?.accountId).toBe("acc-1");
|
|
expect(hookContext?.conversationId).toBe("telegram:999");
|
|
});
|
|
|
|
it("does not broadcast inbound claims without a core-owned plugin binding", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.runner.runInboundClaim.mockResolvedValue({ handled: true } as never);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:-10099",
|
|
To: "telegram:-10099",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
MessageThreadId: 77,
|
|
CommandAuthorized: true,
|
|
WasMentioned: true,
|
|
CommandBody: "who are you",
|
|
RawBody: "who are you",
|
|
Body: "who are you",
|
|
MessageSid: "msg-claim-1",
|
|
SessionKey: "agent:main:hook-test",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: true, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
const [event, hookContext] = firstMockCall(
|
|
hookMocks.runner.runMessageReceived,
|
|
"message received hook",
|
|
) as
|
|
| [
|
|
{ content?: unknown; from?: unknown; metadata?: Record<string, unknown> },
|
|
{ accountId?: unknown; channelId?: unknown; conversationId?: unknown },
|
|
]
|
|
| [];
|
|
expect(event?.from).toBe(ctx.From);
|
|
expect(event?.content).toBe("who are you");
|
|
expect(event?.metadata?.messageId).toBe("msg-claim-1");
|
|
expect(event?.metadata?.originatingChannel).toBe("telegram");
|
|
expect(event?.metadata?.originatingTo).toBe("telegram:-10099");
|
|
expect(event?.metadata?.senderId).toBe("user-9");
|
|
expect(event?.metadata?.senderUsername).toBe("ada");
|
|
expect(event?.metadata?.threadId).toBe(77);
|
|
expect(hookContext?.channelId).toBe("telegram");
|
|
expect(hookContext?.accountId).toBe("default");
|
|
expect(hookContext?.conversationId).toBe("telegram:-10099");
|
|
const internalHookEvent = (
|
|
internalHookMocks.triggerInternalHook.mock.calls as unknown as Array<
|
|
[{ action?: unknown; sessionKey?: unknown; type?: unknown }]
|
|
>
|
|
)[0]?.[0];
|
|
expect(internalHookEvent?.type).toBe("message");
|
|
expect(internalHookEvent?.action).toBe("received");
|
|
expect(internalHookEvent?.sessionKey).toBe("agent:main:hook-test");
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("core reply");
|
|
});
|
|
|
|
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 });
|
|
|
|
const createHookCall = firstMockCall(
|
|
internalHookMocks.createInternalHookEvent,
|
|
"internal hook event",
|
|
) as
|
|
| [
|
|
unknown,
|
|
unknown,
|
|
unknown,
|
|
{
|
|
channelId?: unknown;
|
|
content?: unknown;
|
|
from?: unknown;
|
|
messageId?: unknown;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
]
|
|
| undefined;
|
|
expect(createHookCall?.[0]).toBe("message");
|
|
expect(createHookCall?.[1]).toBe("received");
|
|
expect(createHookCall?.[2]).toBe("agent:main:main");
|
|
expect(createHookCall?.[3]?.from).toBe(ctx.From);
|
|
expect(createHookCall?.[3]?.content).toBe("/help");
|
|
expect(createHookCall?.[3]?.channelId).toBe("telegram");
|
|
expect(createHookCall?.[3]?.messageId).toBe("msg-42");
|
|
expect(createHookCall?.[3]?.metadata?.guildId).toBe("guild-456");
|
|
expect(createHookCall?.[3]?.metadata?.channelName).toBe("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("falls back to CommandTargetSessionKey for internal hook when SessionKey is empty", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
CommandBody: "hello",
|
|
MessageSid: "msg-99",
|
|
});
|
|
(ctx as MsgContext).SessionKey = undefined;
|
|
(ctx as MsgContext).CommandTargetSessionKey = "agent:main:discord:guild:123";
|
|
|
|
const replyResolver = async () => ({ text: "reply" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
const createHookCall = firstMockCall(
|
|
internalHookMocks.createInternalHookEvent,
|
|
"internal hook event",
|
|
) as [unknown, unknown, unknown, { content?: unknown; messageId?: unknown }] | undefined;
|
|
expect(createHookCall?.[0]).toBe("message");
|
|
expect(createHookCall?.[1]).toBe("received");
|
|
expect(createHookCall?.[2]).toBe("agent:main:discord:guild:123");
|
|
expect(createHookCall?.[3]?.content).toBe("hello");
|
|
expect(createHookCall?.[3]?.messageId).toBe("msg-99");
|
|
expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("routes native-command-redirect replies using the redirect target sessionKey for outbound delivery", async () => {
|
|
// Regression test for the native redirect session-key contract:
|
|
// when a native command targets a different session via
|
|
// `CommandTargetSessionKey`, the agent runtime resolves its
|
|
// `params.sessionKey` as `CommandTargetSessionKey ?? SessionKey`
|
|
// (see `get-reply.ts`). Routed reply delivery must mirror that so
|
|
// `agent_end` (fired with the runtime sessionKey) and the outbound
|
|
// `message_sending` hook (fired with `OutboundSessionContext.key`)
|
|
// see the same canonical key. Without this alignment, plugins
|
|
// correlating per-turn state across `agent_end` and `message_sending`
|
|
// would receive divergent keys on every native redirect.
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
AccountId: "acc-1",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
CommandSource: "native",
|
|
SessionKey: "agent:main:slack:channel:CHAN1",
|
|
CommandTargetSessionKey: "agent:main:telegram:direct:999",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
|
|
expect(mocks.routeReply).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
to: "telegram:999",
|
|
sessionKey: "agent:main:telegram:direct:999",
|
|
policySessionKey: "agent:main:telegram:direct:999",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("routes non-native (text) command replies using the inbound sessionKey for outbound delivery", async () => {
|
|
// Companion regression test: for non-native commands the routed
|
|
// reply must keep the inbound `SessionKey` as both the canonical
|
|
// session key and the policy key, even if `CommandTargetSessionKey`
|
|
// happens to be set on the context. This guards against accidental
|
|
// generalization of the native-redirect branch.
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
AccountId: "acc-1",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
CommandSource: "text",
|
|
SessionKey: "agent:main:slack:channel:CHAN1",
|
|
CommandTargetSessionKey: "agent:main:telegram:direct:999",
|
|
});
|
|
|
|
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).toHaveBeenCalledTimes(1);
|
|
expect(mocks.routeReply).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
to: "telegram:999",
|
|
sessionKey: "agent:main:slack:channel:CHAN1",
|
|
policySessionKey: "agent:main:slack:channel:CHAN1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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.logMessageDispatchStarted).toHaveBeenCalledWith({
|
|
channel: "slack",
|
|
sessionKey: "agent:main:main",
|
|
source: "replyResolver",
|
|
});
|
|
expect(diagnosticMocks.logMessageDispatchCompleted).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
channel: "slack",
|
|
outcome: "completed",
|
|
sessionKey: "agent:main:main",
|
|
source: "replyResolver",
|
|
}),
|
|
);
|
|
expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main:main",
|
|
state: "processing",
|
|
reason: "message_start",
|
|
});
|
|
const processedEvent = firstMockArg(
|
|
diagnosticMocks.logMessageProcessed,
|
|
"message processed",
|
|
) as { channel?: unknown; outcome?: unknown; sessionKey?: unknown } | undefined;
|
|
expect(processedEvent?.channel).toBe("slack");
|
|
expect(processedEvent?.outcome).toBe("completed");
|
|
expect(processedEvent?.sessionKey).toBe("agent:main:main");
|
|
});
|
|
|
|
it("marks diagnostic progress for real reply events but not reply start callbacks", async () => {
|
|
setNoAbort();
|
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
SessionKey: "agent:main:main",
|
|
To: "slack:C123",
|
|
});
|
|
const onReplyStart = vi.fn(async () => {});
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload> => {
|
|
await opts?.onReplyStart?.();
|
|
await opts?.onToolResult?.({ text: "tool progress" });
|
|
return { text: "hi" };
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: { onReplyStart },
|
|
replyResolver,
|
|
});
|
|
|
|
expect(onReplyStart).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.markDiagnosticSessionProgress).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.markDiagnosticSessionProgress).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main:main",
|
|
});
|
|
});
|
|
|
|
it("forwards non-answer progress callbacks when source replies are suppressed", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
diagnostics: { enabled: true },
|
|
agents: {
|
|
defaults: {
|
|
verboseDefault: "on",
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
ChatType: "channel",
|
|
SessionKey: "agent:main:discord:channel:C1",
|
|
To: "discord:channel:C1",
|
|
});
|
|
const callbacks = {
|
|
toolStart: vi.fn(async () => {}),
|
|
itemEvent: vi.fn(async () => {}),
|
|
commandOutput: vi.fn(async () => {}),
|
|
};
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload> => {
|
|
await opts?.onToolStart?.({ name: "lookup" });
|
|
await opts?.onItemEvent?.({ progressText: "working" });
|
|
await opts?.onCommandOutput?.({ output: "line", status: "running" });
|
|
return { text: "hi" };
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
allowProgressCallbacksWhenSourceDeliverySuppressed: true,
|
|
onToolStart: callbacks.toolStart,
|
|
onItemEvent: callbacks.itemEvent,
|
|
onCommandOutput: callbacks.commandOutput,
|
|
},
|
|
replyResolver,
|
|
});
|
|
|
|
expect(callbacks.toolStart).toHaveBeenCalledTimes(1);
|
|
expect(callbacks.itemEvent).toHaveBeenCalledTimes(1);
|
|
expect(callbacks.commandOutput).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.markDiagnosticSessionProgress).toHaveBeenCalledTimes(3);
|
|
expect(diagnosticMocks.markDiagnosticSessionProgress).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main:discord:channel:C1",
|
|
});
|
|
});
|
|
|
|
it("routes plugin-owned bindings to the owning plugin before generic inbound claim broadcast", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-1",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1481858418548412579",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
data: {
|
|
kind: "codex-app-server-session",
|
|
version: 1,
|
|
sessionFile: "/tmp/session.jsonl",
|
|
workspaceDir: "/workspace/openclaw",
|
|
},
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:1481858418548412579",
|
|
To: "discord:channel:1481858418548412579",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
CommandBody: "who are you",
|
|
RawBody: "who are you",
|
|
Body: "who are you",
|
|
MessageSid: "msg-claim-plugin-1",
|
|
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-1");
|
|
const inboundClaimCall = hookMocks.runner.runInboundClaimForPluginOutcome.mock
|
|
.calls[0] as unknown as
|
|
| [
|
|
unknown,
|
|
{ accountId?: unknown; channel?: unknown; content?: unknown; conversationId?: unknown },
|
|
{
|
|
accountId?: unknown;
|
|
channelId?: unknown;
|
|
conversationId?: unknown;
|
|
pluginBinding?: { data?: Record<string, unknown> };
|
|
},
|
|
]
|
|
| undefined;
|
|
expect(inboundClaimCall?.[0]).toBe("openclaw-codex-app-server");
|
|
expect(inboundClaimCall?.[1]?.channel).toBe("discord");
|
|
expect(inboundClaimCall?.[1]?.accountId).toBe("default");
|
|
expect(inboundClaimCall?.[1]?.conversationId).toBe("channel:1481858418548412579");
|
|
expect(inboundClaimCall?.[1]?.content).toBe("who are you");
|
|
expect(inboundClaimCall?.[2]?.channelId).toBe("discord");
|
|
expect(inboundClaimCall?.[2]?.accountId).toBe("default");
|
|
expect(inboundClaimCall?.[2]?.conversationId).toBe("channel:1481858418548412579");
|
|
expect(inboundClaimCall?.[2]?.pluginBinding?.data?.kind).toBe("codex-app-server-session");
|
|
expect(inboundClaimCall?.[2]?.pluginBinding?.data?.sessionFile).toBe("/tmp/session.jsonl");
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not run plugin-owned binding delivery when the caller already aborted", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true, reply: { text: "should not send" } },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-aborted-1",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1481858418548412579",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/workspace/openclaw-app-server",
|
|
data: {
|
|
kind: "codex-app-server-session",
|
|
version: 1,
|
|
sessionFile: "/tmp/session.jsonl",
|
|
workspaceDir: "/workspace/openclaw",
|
|
},
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const abortController = new AbortController();
|
|
abortController.abort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:1481858418548412579",
|
|
To: "discord:channel:1481858418548412579",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
CommandBody: "who are you",
|
|
RawBody: "who are you",
|
|
Body: "who are you",
|
|
MessageSid: "msg-claim-plugin-aborted-1",
|
|
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: { abortSignal: abortController.signal },
|
|
replyResolver,
|
|
});
|
|
|
|
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(sessionBindingMocks.touch).not.toHaveBeenCalled();
|
|
expect(hookMocks.runner.runInboundClaimForPluginOutcome).not.toHaveBeenCalled();
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets authorized plugin-owned binding commands fall through to command processing", async () => {
|
|
setNoAbort();
|
|
expect(
|
|
registerPluginCommand(
|
|
"codex",
|
|
{
|
|
name: "codex",
|
|
description: "Control Codex app-server bindings",
|
|
acceptsArgs: true,
|
|
requireAuth: true,
|
|
handler: vi.fn(async () => ({ continueAgent: true })),
|
|
},
|
|
{ allowReservedCommandNames: true },
|
|
),
|
|
).toEqual({ ok: true });
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-command-escape-1",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1481858418548412579",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
detachHint: "/codex detach",
|
|
data: {
|
|
kind: "codex-app-server-session",
|
|
version: 1,
|
|
sessionFile: "/tmp/session.jsonl",
|
|
workspaceDir: "/workspace/openclaw",
|
|
},
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:1481858418548412579",
|
|
To: "discord:channel:1481858418548412579",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandSource: "text",
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
CommandBody: "/codex detach",
|
|
RawBody: "/codex detach",
|
|
Body: "/codex detach",
|
|
MessageSid: "msg-claim-plugin-command-escape",
|
|
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "detached" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: true, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-command-escape-1");
|
|
expect(hookMocks.runner.runInboundClaimForPluginOutcome).not.toHaveBeenCalled();
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("detached");
|
|
});
|
|
|
|
it("keeps authorized unknown slash text in a plugin-owned binding routed to the bound plugin", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-command-unknown-slash",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1481858418548412579",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:1481858418548412579",
|
|
To: "discord:channel:1481858418548412579",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandSource: "text",
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
CommandBody: "/notes keep this with the bound plugin",
|
|
RawBody: "/notes keep this with the bound plugin",
|
|
Body: "/notes keep this with the bound plugin",
|
|
MessageSid: "msg-claim-plugin-command-unknown-slash",
|
|
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-command-unknown-slash");
|
|
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
|
|
"openclaw-codex-app-server",
|
|
expect.objectContaining({ content: "/notes keep this with the bound plugin" }),
|
|
expect.objectContaining({
|
|
pluginBinding: expect.objectContaining({ bindingId: "binding-command-unknown-slash" }),
|
|
}),
|
|
);
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps unauthorized plugin-owned binding slash text routed to the bound plugin", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-command-escape-denied",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1481858418548412579",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
detachHint: "/codex detach",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:1481858418548412579",
|
|
To: "discord:channel:1481858418548412579",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandSource: "text",
|
|
CommandAuthorized: false,
|
|
WasMentioned: false,
|
|
CommandBody: "/codex detach",
|
|
RawBody: "/codex detach",
|
|
Body: "/codex detach",
|
|
MessageSid: "msg-claim-plugin-command-denied",
|
|
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-command-escape-denied");
|
|
expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith(
|
|
"openclaw-codex-app-server",
|
|
expect.objectContaining({ content: "/codex detach" }),
|
|
expect.objectContaining({
|
|
pluginBinding: expect.objectContaining({ bindingId: "binding-command-escape-denied" }),
|
|
}),
|
|
);
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers plugin-owned binding replies returned by the owning inbound claim hook", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "codex", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true, reply: { text: "Codex native reply" } },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-reply-1",
|
|
targetSessionKey: "plugin-binding:codex:reply123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:1481858418548412579",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "codex",
|
|
pluginRoot: "/plugins/codex",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:1481858418548412579",
|
|
To: "discord:channel:1481858418548412579",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
CommandBody: "who are you",
|
|
RawBody: "who are you",
|
|
Body: "who are you",
|
|
MessageSid: "msg-claim-plugin-reply",
|
|
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "Codex native reply" });
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes plugin-owned Discord DM bindings to the owning plugin before generic inbound claim broadcast", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-dm-1",
|
|
targetSessionKey: "plugin-binding:codex:dm123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "user:1177378744822943744",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
From: "discord:1177378744822943744",
|
|
OriginatingTo: "channel:1480574946919846079",
|
|
To: "channel:1480574946919846079",
|
|
AccountId: "default",
|
|
SenderId: "user-9",
|
|
SenderUsername: "ada",
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
CommandBody: "who are you",
|
|
RawBody: "who are you",
|
|
Body: "who are you",
|
|
MessageSid: "msg-claim-plugin-dm-1",
|
|
SessionKey: "agent:main:discord:user:1177378744822943744",
|
|
});
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-dm-1");
|
|
const inboundClaimCall = hookMocks.runner.runInboundClaimForPluginOutcome.mock
|
|
.calls[0] as unknown as
|
|
| [
|
|
unknown,
|
|
{ accountId?: unknown; channel?: unknown; content?: unknown; conversationId?: unknown },
|
|
{ accountId?: unknown; channelId?: unknown; conversationId?: unknown },
|
|
]
|
|
| undefined;
|
|
expect(inboundClaimCall?.[0]).toBe("openclaw-codex-app-server");
|
|
expect(inboundClaimCall?.[1]?.channel).toBe("discord");
|
|
expect(inboundClaimCall?.[1]?.accountId).toBe("default");
|
|
expect(inboundClaimCall?.[1]?.conversationId).toBe("1480574946919846079");
|
|
expect(inboundClaimCall?.[1]?.content).toBe("who are you");
|
|
expect(inboundClaimCall?.[2]?.channelId).toBe("discord");
|
|
expect(inboundClaimCall?.[2]?.accountId).toBe("default");
|
|
expect(inboundClaimCall?.[2]?.conversationId).toBe("1480574946919846079");
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to OpenClaw once per startup when a bound plugin is missing", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "missing_plugin",
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-missing-1",
|
|
targetSessionKey: "plugin-binding:codex:missing123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:missing-plugin",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginName: "Codex App Server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
detachHint: "/codex_detach",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
|
|
const replyResolver = vi.fn(async () => ({ text: "openclaw fallback" }) satisfies ReplyPayload);
|
|
|
|
const firstDispatcher = createDispatcher();
|
|
await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:missing-plugin",
|
|
To: "discord:channel:missing-plugin",
|
|
AccountId: "default",
|
|
MessageSid: "msg-missing-plugin-1",
|
|
SessionKey: "agent:main:discord:channel:missing-plugin",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher: firstDispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
const firstNotice = (firstDispatcher.sendToolResult as ReturnType<typeof vi.fn>).mock
|
|
.calls[0]?.[0] as ReplyPayload | undefined;
|
|
expect(firstNotice?.text).toContain("is not currently loaded.");
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
|
|
replyResolver.mockClear();
|
|
hookMocks.runner.runInboundClaim.mockClear();
|
|
|
|
const secondDispatcher = createDispatcher();
|
|
await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:missing-plugin",
|
|
To: "discord:channel:missing-plugin",
|
|
AccountId: "default",
|
|
MessageSid: "msg-missing-plugin-2",
|
|
SessionKey: "agent:main:discord:channel:missing-plugin",
|
|
CommandBody: "still there?",
|
|
RawBody: "still there?",
|
|
Body: "still there?",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher: secondDispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(secondDispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to OpenClaw when the bound plugin is loaded but has no inbound_claim handler", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "no_handler",
|
|
});
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-no-handler-1",
|
|
targetSessionKey: "plugin-binding:codex:nohandler123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:no-handler",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginName: "Codex App Server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "openclaw fallback" }) satisfies ReplyPayload);
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:no-handler",
|
|
To: "discord:channel:no-handler",
|
|
AccountId: "default",
|
|
MessageSid: "msg-no-handler-1",
|
|
SessionKey: "agent:main:discord:channel:no-handler",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
const notice = firstMockArg(
|
|
dispatcher.sendToolResult as ReturnType<typeof vi.fn>,
|
|
"tool result",
|
|
) as ReplyPayload | undefined;
|
|
expect(notice?.text).toContain("is not currently loaded.");
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("notifies the user when a bound plugin declines the turn and keeps the binding attached", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "declined",
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-declined-1",
|
|
targetSessionKey: "plugin-binding:codex:declined123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:declined",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginName: "Codex App Server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
detachHint: "/codex_detach",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:declined",
|
|
To: "discord:channel:declined",
|
|
AccountId: "default",
|
|
MessageSid: "msg-declined-1",
|
|
SessionKey: "agent:main:discord:channel:declined",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
const finalNotice = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
|
|
.calls[0]?.[0] as ReplyPayload | undefined;
|
|
expect(finalNotice?.text).toContain("Plugin binding request was declined.");
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("notifies the user when a bound plugin errors and keeps raw details out of the reply", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "error",
|
|
error: "boom",
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-error-1",
|
|
targetSessionKey: "plugin-binding:codex:error123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:error",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginName: "Codex App Server",
|
|
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:error",
|
|
To: "discord:channel:error",
|
|
AccountId: "default",
|
|
MessageSid: "msg-error-1",
|
|
SessionKey: "agent:main:discord:channel:error",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
const finalNotice = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
|
|
.calls[0]?.[0] as ReplyPayload | undefined;
|
|
expect(finalNotice?.text).toContain("Plugin binding request failed.");
|
|
expect(finalNotice?.text).not.toContain("boom");
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
const skippedEvent = diagnosticMocks.logMessageProcessed.mock.calls
|
|
.map(([event]) => event as { channel?: unknown; outcome?: unknown; reason?: unknown })
|
|
.find((event) => event.outcome === "skipped");
|
|
expect(skippedEvent?.channel).toBe("whatsapp");
|
|
expect(skippedEvent?.reason).toBe("duplicate");
|
|
});
|
|
|
|
it("releases inbound dedupe when dispatch fails before completion", async () => {
|
|
setNoAbort();
|
|
const cfg = { diagnostics: { enabled: true } } as OpenClawConfig;
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550124",
|
|
To: "whatsapp:+15555550124",
|
|
AccountId: "default",
|
|
MessageSid: "msg-dup-error",
|
|
SessionKey: "agent:main:whatsapp:direct:+15555550124",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
});
|
|
const replyResolver = vi
|
|
.fn<
|
|
(_ctx: MsgContext, _opts?: GetReplyOptions, _cfg?: OpenClawConfig) => Promise<ReplyPayload>
|
|
>()
|
|
.mockRejectedValueOnce(new Error("dispatch failed"))
|
|
.mockResolvedValueOnce({ text: "retry succeeds" });
|
|
|
|
await expect(
|
|
dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher: createDispatcher(),
|
|
replyResolver,
|
|
}),
|
|
).rejects.toThrow("dispatch failed");
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher: createDispatcher(),
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(2);
|
|
const errorEvent = diagnosticMocks.logMessageProcessed.mock.calls
|
|
.map(([event]) => event as { channel?: unknown; error?: unknown; outcome?: unknown })
|
|
.find((event) => event.outcome === "error");
|
|
expect(errorEvent?.channel).toBe("whatsapp");
|
|
expect(errorEvent?.error).toBe("Error: dispatch failed");
|
|
});
|
|
|
|
it("poisons inbound dedupe when dispatch fails after a block reply", async () => {
|
|
setNoAbort();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550125",
|
|
To: "whatsapp:+15555550125",
|
|
AccountId: "default",
|
|
MessageSid: "msg-dup-block-error",
|
|
SessionKey: "agent:main:whatsapp:direct:+15555550125",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
});
|
|
const firstDispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(
|
|
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "partial answer" });
|
|
throw new Error("provider failed after block");
|
|
},
|
|
);
|
|
|
|
await expect(
|
|
dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher: firstDispatcher,
|
|
replyResolver,
|
|
}),
|
|
).rejects.toThrow("provider failed after block");
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher: createDispatcher(),
|
|
replyResolver,
|
|
});
|
|
|
|
expect(firstDispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "partial answer" });
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("poisons inbound dedupe when dispatch fails after a suppressed tool result", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+15555550126",
|
|
To: "whatsapp:+15555550126",
|
|
AccountId: "default",
|
|
MessageSid: "msg-dup-tool-error",
|
|
SessionKey: "agent:main:whatsapp:direct:+15555550126",
|
|
CommandBody: "hello",
|
|
RawBody: "hello",
|
|
Body: "hello",
|
|
});
|
|
const firstDispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(
|
|
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onToolResult?.({ text: "tool touched external state" });
|
|
throw new Error("provider failed after tool");
|
|
},
|
|
);
|
|
|
|
await expect(
|
|
dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher: firstDispatcher,
|
|
replyResolver,
|
|
}),
|
|
).rejects.toThrow("provider failed after tool");
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher: createDispatcher(),
|
|
replyResolver,
|
|
});
|
|
|
|
expect(firstDispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes the loaded config plus configOverride patch to replyResolver when provided", async () => {
|
|
setNoAbort();
|
|
const cfg = emptyConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "msteams", Surface: "msteams" });
|
|
|
|
const overrideCfg = {
|
|
agents: { defaults: { userTimezone: "America/New_York" } },
|
|
} as OpenClawConfig;
|
|
|
|
let receivedCfg: OpenClawConfig | undefined;
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
cfgArg?: OpenClawConfig,
|
|
) => {
|
|
receivedCfg = cfgArg;
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
configOverride: overrideCfg,
|
|
});
|
|
|
|
expect(receivedCfg).not.toBe(cfg);
|
|
expect(receivedCfg).not.toBe(overrideCfg);
|
|
expect(receivedCfg).toEqual(overrideCfg);
|
|
});
|
|
|
|
it("passes the already loaded config to replyResolver when configOverride is not provided", async () => {
|
|
setNoAbort();
|
|
const cfg = { agents: { defaults: { userTimezone: "UTC" } } } as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "telegram", Surface: "telegram" });
|
|
|
|
let receivedCfg: OpenClawConfig | undefined;
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
_opts?: GetReplyOptions,
|
|
cfgArg?: OpenClawConfig,
|
|
) => {
|
|
receivedCfg = cfgArg;
|
|
return { text: "hi" } satisfies ReplyPayload;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
|
|
expect(receivedCfg).toBe(cfg);
|
|
});
|
|
|
|
it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const replyResolver = async () =>
|
|
[
|
|
{ text: "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] as ReplyPayload | undefined)?.text).toBe("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: "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("thinking...");
|
|
expect(blockReplySentTexts).toContain("The answer is 42");
|
|
});
|
|
|
|
it("strips split TTS directives from streamed block text before delivery", async () => {
|
|
setNoAbort();
|
|
ttsMocks.state.synthesizeFinalAudio = true;
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const blockReplySentTexts: string[] = [];
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "Intro [[tts:te" });
|
|
await opts?.onBlockReply?.({ text: "xt]]hidden[[/tts:text]] visible" });
|
|
return undefined;
|
|
};
|
|
(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).toEqual(["Intro ", " visible"]);
|
|
expect(blockReplySentTexts.join("")).not.toContain("[[tts");
|
|
expect(blockReplySentTexts.join("")).not.toContain("hidden");
|
|
const ttsCall = ttsMocks.maybeApplyTtsToPayload.mock.calls
|
|
.map(([call]) => call as { kind?: unknown; payload?: ReplyPayload })
|
|
.find((call) => call.kind === "final");
|
|
expect(ttsCall?.kind).toBe("final");
|
|
expect(ttsCall?.payload).toEqual({ text: "Intro [[tts:text]]hidden[[/tts:text]] visible" });
|
|
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");
|
|
});
|
|
|
|
it("forwards generated-media block replies in WhatsApp group sessions", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
ChatType: "group",
|
|
From: "whatsapp:120363111111111@g.us",
|
|
To: "whatsapp:120363111111111@g.us",
|
|
SessionKey: "agent:main:whatsapp:group:120363111111111@g.us",
|
|
});
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload> => {
|
|
await opts?.onBlockReply?.({
|
|
text: "generated",
|
|
mediaUrls: ["https://example.com/generated.png"],
|
|
});
|
|
return { text: "NO_REPLY" };
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: automaticGroupReplyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(dispatcher.sendBlockReply).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendBlockReply).toHaveBeenCalledWith({
|
|
text: "generated",
|
|
mediaUrls: ["https://example.com/generated.png"],
|
|
});
|
|
});
|
|
|
|
it("signals block boundaries before async block delivery is queued", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const callOrder: string[] = [];
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "The answer is 42" });
|
|
return undefined;
|
|
};
|
|
|
|
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
|
|
(payload: ReplyPayload) => {
|
|
callOrder.push(`dispatch:${payload.text}`);
|
|
return true;
|
|
},
|
|
);
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
onBlockReplyQueued: (payload) => {
|
|
callOrder.push(`queued:${payload.text}`);
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(callOrder).toEqual(["queued:The answer is 42", "dispatch:The answer is 42"]);
|
|
});
|
|
|
|
it("does not wait for same-channel block dispatcher delivery before resolving block replies", async () => {
|
|
setNoAbort();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const delivered: ReplyPayload[] = [];
|
|
let releaseDelivery: (() => void) | undefined;
|
|
let markDeliveryStarted: (() => void) | undefined;
|
|
const deliveryStarted = new Promise<void>((resolve) => {
|
|
markDeliveryStarted = resolve;
|
|
});
|
|
const deliveryGate = new Promise<void>((resolve) => {
|
|
releaseDelivery = resolve;
|
|
});
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver: async (payload) => {
|
|
delivered.push(payload);
|
|
markDeliveryStarted?.();
|
|
await deliveryGate;
|
|
},
|
|
});
|
|
let blockReplySettled = false;
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
const blockReplyPromise = Promise.resolve(opts?.onBlockReply?.({ text: "before tool" })).then(
|
|
() => {
|
|
blockReplySettled = true;
|
|
},
|
|
);
|
|
|
|
await deliveryStarted;
|
|
|
|
expect(delivered).toEqual([{ text: "before tool" }]);
|
|
await blockReplyPromise;
|
|
expect(blockReplySettled).toBe(true);
|
|
|
|
releaseDelivery?.();
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(blockReplySettled).toBe(true);
|
|
await dispatcher.waitForIdle();
|
|
});
|
|
|
|
it("waits for pending same-channel block delivery before completing block-only dispatch", async () => {
|
|
setNoAbort();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const delivered: ReplyPayload[] = [];
|
|
let releaseDelivery: (() => void) | undefined;
|
|
let markDeliveryStarted: (() => void) | undefined;
|
|
const deliveryStarted = new Promise<void>((resolve) => {
|
|
markDeliveryStarted = resolve;
|
|
});
|
|
const deliveryGate = new Promise<void>((resolve) => {
|
|
releaseDelivery = resolve;
|
|
});
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver: async (payload) => {
|
|
delivered.push(payload);
|
|
markDeliveryStarted?.();
|
|
await deliveryGate;
|
|
},
|
|
});
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "only block" });
|
|
return undefined;
|
|
};
|
|
|
|
let dispatchSettled = false;
|
|
const dispatchPromise = dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
}).then((result) => {
|
|
dispatchSettled = true;
|
|
return result;
|
|
});
|
|
|
|
await deliveryStarted;
|
|
|
|
expect(delivered).toEqual([{ text: "only block" }]);
|
|
expect(dispatchSettled).toBe(false);
|
|
|
|
releaseDelivery?.();
|
|
await dispatchPromise;
|
|
|
|
expect(dispatchSettled).toBe(true);
|
|
});
|
|
|
|
it("waits for pending same-channel block delivery before forwarding tool progress", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
agents: { defaults: { verboseDefault: "on" } },
|
|
} as const satisfies OpenClawConfig;
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const delivered: ReplyPayload[] = [];
|
|
let releaseDelivery: (() => void) | undefined;
|
|
let markDeliveryStarted: (() => void) | undefined;
|
|
const deliveryStarted = new Promise<void>((resolve) => {
|
|
markDeliveryStarted = resolve;
|
|
});
|
|
const deliveryGate = new Promise<void>((resolve) => {
|
|
releaseDelivery = resolve;
|
|
});
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver: async (payload) => {
|
|
delivered.push(payload);
|
|
markDeliveryStarted?.();
|
|
await deliveryGate;
|
|
},
|
|
});
|
|
const onToolStart = vi.fn();
|
|
let toolProgressSettled = false;
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "before tool" });
|
|
const toolProgressPromise = Promise.resolve(opts?.onToolStart?.({ name: "lookup" })).then(
|
|
() => {
|
|
toolProgressSettled = true;
|
|
},
|
|
);
|
|
|
|
await deliveryStarted;
|
|
|
|
expect(delivered).toEqual([{ text: "before tool" }]);
|
|
expect(onToolStart).not.toHaveBeenCalled();
|
|
expect(toolProgressSettled).toBe(false);
|
|
|
|
releaseDelivery?.();
|
|
await toolProgressPromise;
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { onToolStart },
|
|
});
|
|
|
|
expect(toolProgressSettled).toBe(true);
|
|
expect(onToolStart).toHaveBeenCalledWith({ name: "lookup" });
|
|
});
|
|
|
|
it("does not synthesize tool-start capability while ordering item progress", async () => {
|
|
setNoAbort();
|
|
const cfg = {
|
|
agents: { defaults: { verboseDefault: "on" } },
|
|
} as const satisfies OpenClawConfig;
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const delivered: ReplyPayload[] = [];
|
|
let releaseDelivery: (() => void) | undefined;
|
|
let markDeliveryStarted: (() => void) | undefined;
|
|
const deliveryStarted = new Promise<void>((resolve) => {
|
|
markDeliveryStarted = resolve;
|
|
});
|
|
const deliveryGate = new Promise<void>((resolve) => {
|
|
releaseDelivery = resolve;
|
|
});
|
|
const dispatcher = createReplyDispatcher({
|
|
deliver: async (payload) => {
|
|
delivered.push(payload);
|
|
markDeliveryStarted?.();
|
|
await deliveryGate;
|
|
},
|
|
});
|
|
const onItemEvent = vi.fn();
|
|
let itemProgressSettled = false;
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
await opts?.onBlockReply?.({ text: "before item" });
|
|
expect(opts?.onToolStart).toBeUndefined();
|
|
const itemProgressPromise = Promise.resolve(
|
|
opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running" }),
|
|
).then(() => {
|
|
itemProgressSettled = true;
|
|
});
|
|
|
|
await deliveryStarted;
|
|
|
|
expect(delivered).toEqual([{ text: "before item" }]);
|
|
expect(onItemEvent).not.toHaveBeenCalled();
|
|
expect(itemProgressSettled).toBe(false);
|
|
|
|
releaseDelivery?.();
|
|
await itemProgressPromise;
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { onItemEvent },
|
|
});
|
|
|
|
expect(itemProgressSettled).toBe(true);
|
|
expect(onItemEvent).toHaveBeenCalledWith({
|
|
itemId: "1",
|
|
kind: "tool",
|
|
progressText: "running",
|
|
});
|
|
});
|
|
|
|
it("forwards payload metadata into onBlockReplyQueued context", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({ Provider: "whatsapp" });
|
|
const onBlockReplyQueued = vi.fn();
|
|
const { setReplyPayloadMetadata } = await import("../types.js");
|
|
const replyResolver = async (
|
|
_ctx: MsgContext,
|
|
opts?: GetReplyOptions,
|
|
): Promise<ReplyPayload | undefined> => {
|
|
const payload = setReplyPayloadMetadata({ text: "Alpha" }, { assistantMessageIndex: 7 });
|
|
await opts?.onBlockReply?.(payload);
|
|
return undefined;
|
|
};
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: { onBlockReplyQueued },
|
|
});
|
|
|
|
expect(onBlockReplyQueued).toHaveBeenCalledWith(
|
|
{ text: "Alpha" },
|
|
{ assistantMessageIndex: 7 },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("before_dispatch hook", () => {
|
|
const createHookCtx = (overrides: Partial<MsgContext> = {}) =>
|
|
buildTestCtx({
|
|
Body: "hello",
|
|
BodyForAgent: "hello",
|
|
BodyForCommands: "hello",
|
|
From: "user1",
|
|
Surface: "telegram",
|
|
ChatType: "private",
|
|
...overrides,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetInboundDedupe();
|
|
mocks.routeReply.mockReset();
|
|
mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
|
threadInfoMocks.parseSessionThreadInfo.mockReset();
|
|
threadInfoMocks.parseSessionThreadInfo.mockImplementation(parseGenericThreadSessionInfo);
|
|
ttsMocks.state.synthesizeFinalAudio = false;
|
|
ttsMocks.maybeApplyTtsToPayload.mockClear();
|
|
setNoAbort();
|
|
hookMocks.runner.runBeforeDispatch.mockClear();
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue(undefined);
|
|
hookMocks.runner.runReplyDispatch.mockClear();
|
|
hookMocks.runner.runReplyDispatch.mockResolvedValue(undefined);
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
(hookName?: string) => hookName === "before_dispatch",
|
|
);
|
|
});
|
|
|
|
it("skips model dispatch when hook returns handled", async () => {
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true, text: "Blocked" });
|
|
const dispatcher = createDispatcher();
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: createHookCtx(),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
});
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "Blocked" });
|
|
expect(result.queuedFinal).toBe(true);
|
|
});
|
|
|
|
it("silently short-circuits when hook returns handled without text", async () => {
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true });
|
|
const dispatcher = createDispatcher();
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: createHookCtx(),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
});
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(result.queuedFinal).toBe(false);
|
|
});
|
|
|
|
it("uses canonical hook metadata and shared routed final delivery", async () => {
|
|
ttsMocks.state.synthesizeFinalAudio = true;
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true, text: "Blocked" });
|
|
const dispatcher = createDispatcher();
|
|
const ctx = createHookCtx({
|
|
Body: "raw body",
|
|
BodyForAgent: "agent body",
|
|
BodyForCommands: "command body",
|
|
Provider: "slack",
|
|
Surface: "slack",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:999",
|
|
From: "signal:group:ops-room",
|
|
SenderId: "signal:user:alice",
|
|
GroupChannel: "ops-room",
|
|
ChatType: "direct",
|
|
Timestamp: 123,
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher });
|
|
|
|
const beforeDispatchCall = firstMockCall(
|
|
hookMocks.runner.runBeforeDispatch,
|
|
"before dispatch hook",
|
|
) as
|
|
| [
|
|
{
|
|
body?: unknown;
|
|
channel?: unknown;
|
|
content?: unknown;
|
|
isGroup?: unknown;
|
|
senderId?: unknown;
|
|
timestamp?: unknown;
|
|
},
|
|
{ channelId?: unknown; senderId?: unknown },
|
|
]
|
|
| undefined;
|
|
expect(beforeDispatchCall?.[0]?.content).toBe("command body");
|
|
expect(beforeDispatchCall?.[0]?.body).toBe("agent body");
|
|
expect(beforeDispatchCall?.[0]?.channel).toBe("telegram");
|
|
expect(beforeDispatchCall?.[0]?.senderId).toBe("signal:user:alice");
|
|
expect(beforeDispatchCall?.[0]?.isGroup).toBe(true);
|
|
expect(beforeDispatchCall?.[0]?.timestamp).toBe(123);
|
|
expect(beforeDispatchCall?.[1]?.channelId).toBe("telegram");
|
|
expect(beforeDispatchCall?.[1]?.senderId).toBe("signal:user:alice");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
const routeCall = firstRouteReplyCall() as
|
|
| { channel?: unknown; payload?: ReplyPayload; to?: unknown }
|
|
| undefined;
|
|
expect(routeCall?.channel).toBe("telegram");
|
|
expect(routeCall?.to).toBe("telegram:999");
|
|
expect(routeCall?.payload?.text).toBe("Blocked");
|
|
expect(routeCall?.payload?.mediaUrl).toBe("https://example.com/tts-synth.opus");
|
|
expect(routeCall?.payload?.audioAsVoice).toBe(true);
|
|
expect(result.queuedFinal).toBe(true);
|
|
});
|
|
|
|
it("suppresses before_dispatch handled reply when sendPolicy is deny", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true, text: "Blocked" });
|
|
const dispatcher = createDispatcher();
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: createHookCtx({ SessionKey: "test:session" }),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
});
|
|
// Hook handled the message (no model dispatch)
|
|
expect(hookMocks.runner.runBeforeDispatch).toHaveBeenCalled();
|
|
// But delivery must be suppressed
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
|
expect(result.queuedFinal).toBe(false);
|
|
});
|
|
|
|
it("continues default dispatch when hook returns not handled", async () => {
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: false });
|
|
const dispatcher = createDispatcher();
|
|
await dispatchReplyFromConfig({
|
|
ctx: createHookCtx(),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver: async () => ({ text: "model reply" }),
|
|
});
|
|
expect(hookMocks.runner.runBeforeDispatch).toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "model reply" });
|
|
});
|
|
});
|
|
|
|
describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => {
|
|
beforeEach(() => {
|
|
resetInboundDedupe();
|
|
sessionStoreMocks.currentEntry = undefined;
|
|
sessionBindingMocks.resolveByConversation.mockReset();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue(null);
|
|
sessionBindingMocks.touch.mockReset();
|
|
hookMocks.registry.plugins = [];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "no_handler",
|
|
});
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
(hookName?: string) => hookName === "reply_dispatch",
|
|
);
|
|
hookMocks.runner.runReplyDispatch.mockResolvedValue(undefined);
|
|
hookMocks.runner.runBeforeDispatch.mockResolvedValue(undefined);
|
|
threadInfoMocks.parseSessionThreadInfo.mockReset();
|
|
threadInfoMocks.parseSessionThreadInfo.mockImplementation(parseGenericThreadSessionInfo);
|
|
});
|
|
|
|
it("still calls the replyResolver when sendPolicy is deny", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.suppressTyping).toBe(true);
|
|
return { text: "agent reply" } satisfies ReplyPayload;
|
|
});
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
// The agent MUST process the message (replyResolver called)
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes suppressUserDelivery to tail reply_dispatch when sendPolicy is deny", async () => {
|
|
setNoAbort();
|
|
diagnosticMocks.logMessageDispatchStarted.mockClear();
|
|
diagnosticMocks.logMessageDispatchCompleted.mockClear();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
hookMocks.runner.runReplyDispatch.mockImplementation(async (event: unknown) => {
|
|
const candidate = event as { isTailDispatch?: boolean };
|
|
if (candidate.isTailDispatch) {
|
|
return {
|
|
handled: true,
|
|
queuedFinal: false,
|
|
counts: { tool: 0, block: 0, final: 0 },
|
|
};
|
|
}
|
|
return undefined;
|
|
});
|
|
const dispatcher = createDispatcher();
|
|
const ctx = buildTestCtx({
|
|
SessionKey: "test:session",
|
|
AcpDispatchTailAfterReset: true,
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: { diagnostics: { enabled: true } } as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver: async () => ({ text: "agent reply" }),
|
|
});
|
|
|
|
const tailDispatchCall = hookMocks.runner.runReplyDispatch.mock.calls.find(
|
|
([event]) => (event as { isTailDispatch?: boolean }).isTailDispatch === true,
|
|
);
|
|
const tailDispatchEvent = tailDispatchCall?.[0] as
|
|
| {
|
|
isTailDispatch?: unknown;
|
|
sendPolicy?: unknown;
|
|
suppressReplyLifecycle?: unknown;
|
|
suppressUserDelivery?: unknown;
|
|
}
|
|
| undefined;
|
|
expect(tailDispatchEvent?.isTailDispatch).toBe(true);
|
|
expect(tailDispatchEvent?.sendPolicy).toBe("deny");
|
|
expect(tailDispatchEvent?.suppressUserDelivery).toBe(true);
|
|
expect(tailDispatchEvent?.suppressReplyLifecycle).toBe(true);
|
|
if (tailDispatchCall?.[1] === undefined) {
|
|
throw new Error("Expected tail dispatch metadata");
|
|
}
|
|
expect(diagnosticMocks.logMessageDispatchStarted).toHaveBeenCalledTimes(1);
|
|
expect(diagnosticMocks.logMessageDispatchCompleted).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
outcome: "completed",
|
|
sessionKey: "test:session",
|
|
source: "replyResolver",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("suppresses final reply delivery when sendPolicy is deny", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "agent reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
// Delivery MUST be suppressed
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(result.queuedFinal).toBe(false);
|
|
});
|
|
|
|
it("suppresses tool result delivery when sendPolicy is deny", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
let capturedOnToolResult: ((payload: ReplyPayload) => Promise<void>) | undefined;
|
|
const replyResolver = vi.fn(
|
|
async (_ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig) => {
|
|
capturedOnToolResult = opts?.onToolResult as
|
|
| ((payload: ReplyPayload) => Promise<void>)
|
|
| undefined;
|
|
return { text: "reply" } satisfies ReplyPayload;
|
|
},
|
|
);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
// Trigger a tool result — delivery should be suppressed
|
|
await requireToolResultHandler(capturedOnToolResult)({ text: "tool output" });
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses block reply delivery when sendPolicy is deny", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
let capturedOnBlockReply:
|
|
| ((payload: ReplyPayload, context?: unknown) => Promise<void>)
|
|
| undefined;
|
|
const replyResolver = vi.fn(
|
|
async (_ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig) => {
|
|
capturedOnBlockReply = opts?.onBlockReply as
|
|
| ((payload: ReplyPayload, context?: unknown) => Promise<void>)
|
|
| undefined;
|
|
return [] as ReplyPayload[];
|
|
},
|
|
);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
// Trigger a block reply — delivery should be suppressed
|
|
await requireBlockReplyHandler(capturedOnBlockReply)({ text: "streaming chunk" });
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers replies normally when sendPolicy is allow", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "agent reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("delivers provider conversation-state runner payloads as outbound channel replies", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const exactProviderError = "Custom tool call output is missing for call id: call_live_123.";
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (receivedCtx: MsgContext) => {
|
|
expect(receivedCtx.Body).toBe(exactProviderError);
|
|
return {
|
|
text: PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
|
} satisfies ReplyPayload;
|
|
});
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:provider-error",
|
|
To: "discord:channel:provider-error",
|
|
AccountId: "default",
|
|
SessionKey: "agent:main:discord:channel:provider-error",
|
|
Body: exactProviderError,
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
|
text: PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE,
|
|
});
|
|
});
|
|
|
|
it("delivers replies normally when sendPolicy is unset (defaults to allow)", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "agent reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses the fast-abort reply under sendPolicy deny", async () => {
|
|
// Fast-abort runs before sendPolicy in the old code, so the abort reply
|
|
// leaked. Under the guard, the abort is still recorded but no reply is
|
|
// dispatched. See #53328.
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
handled: true,
|
|
aborted: true,
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Body: "/stop",
|
|
SessionKey: "test:session",
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(result.queuedFinal).toBe(false);
|
|
});
|
|
|
|
it("delivers the fast-abort reply normally when sendPolicy is allow (regression guard)", async () => {
|
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
handled: true,
|
|
aborted: true,
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "hi" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Body: "/stop",
|
|
SessionKey: "test:session",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({
|
|
text: "⚙️ Agent was aborted.",
|
|
});
|
|
});
|
|
|
|
it("skips plugin-bound claim hook under deny and falls through to suppressed agent dispatch", async () => {
|
|
// Plugin-bound inbound handlers can emit outbound replies we cannot
|
|
// rewind. Under deny, skip the plugin claim entirely and let the agent
|
|
// process the message with delivery suppressed. See #53328.
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-deny",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:deny-test",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/tmp/plugin",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "agent reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "discord:channel:deny-test",
|
|
To: "discord:channel:deny-test",
|
|
AccountId: "default",
|
|
SessionKey: "agent:main:discord:channel:deny-test",
|
|
Body: "observed message",
|
|
});
|
|
|
|
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
|
|
|
|
// Binding is still tracked (touch runs before the gate)...
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-deny");
|
|
// ...but the plugin claim hook MUST NOT be invoked under deny — the
|
|
// plugin can't be trusted to honor suppressDelivery on its outbound path.
|
|
expect(hookMocks.runner.runInboundClaimForPluginOutcome).not.toHaveBeenCalled();
|
|
// Agent still processes the message (the whole point of the PR)...
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
// ...but no final reply is delivered.
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes plugin-owned bindings under message-tool-only source delivery", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-message-tool-only",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:11",
|
|
parentConversationId: "-1001234567890",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/tmp/plugin",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "agent reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:-1001234567890",
|
|
To: "telegram:-1001234567890",
|
|
AccountId: "default",
|
|
MessageThreadId: 11,
|
|
ChatType: "group",
|
|
GroupSubject: "Dev",
|
|
Body: "observed message",
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
queuedFinal: false,
|
|
counts: { tool: 0, block: 0, final: 0 },
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-message-tool-only");
|
|
const claimCall = firstMockCall(
|
|
hookMocks.runner.runInboundClaimForPluginOutcome,
|
|
"plugin inbound claim",
|
|
);
|
|
expect(claimCall[0]).toBe("openclaw-codex-app-server");
|
|
expect(claimCall[1]).toMatchObject({
|
|
channel: "telegram",
|
|
content: "observed message",
|
|
threadId: 11,
|
|
});
|
|
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
|
|
expect(claimContext.pluginBinding).toMatchObject({ bindingId: "binding-message-tool-only" });
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps unmentioned plugin-bound fallback from ordinary group agent dispatch", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "no_handler",
|
|
});
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-message-tool-fallback",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:11",
|
|
parentConversationId: "-1001234567890",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/tmp/plugin",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "agent reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:-1001234567890",
|
|
To: "telegram:-1001234567890",
|
|
AccountId: "default",
|
|
MessageThreadId: 11,
|
|
ChatType: "group",
|
|
GroupSubject: "Dev",
|
|
Body: "observed message",
|
|
WasMentioned: false,
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
queuedFinal: false,
|
|
counts: { tool: 0, block: 0, final: 0 },
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
const claimCall = firstMockCall(
|
|
hookMocks.runner.runInboundClaimForPluginOutcome,
|
|
"plugin inbound claim",
|
|
);
|
|
expect(claimCall[0]).toBe("openclaw-codex-app-server");
|
|
expect(claimCall[1]).toMatchObject({
|
|
channel: "telegram",
|
|
content: "observed message",
|
|
threadId: 11,
|
|
});
|
|
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
|
|
expect(claimContext.pluginBinding).toMatchObject({
|
|
bindingId: "binding-message-tool-fallback",
|
|
});
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets authorized control commands without CommandSource escape plugin-bound fallback", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "no_handler",
|
|
});
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-message-tool-command",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:11",
|
|
parentConversationId: "-1001234567890",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/tmp/plugin",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const cfg = { messages: { visibleReplies: "message_tool" } } as OpenClawConfig;
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "reset ack" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:-1001234567890",
|
|
To: "telegram:-1001234567890",
|
|
AccountId: "default",
|
|
MessageThreadId: 11,
|
|
ChatType: "group",
|
|
GroupSubject: "Dev",
|
|
Body: "/reset@openclaw",
|
|
RawBody: "/reset@openclaw",
|
|
CommandBody: "/reset@openclaw",
|
|
BotUsername: "openclaw",
|
|
CommandSource: undefined,
|
|
CommandAuthorized: true,
|
|
WasMentioned: false,
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(hookMocks.runner.runInboundClaimForPluginOutcome).not.toHaveBeenCalled();
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "reset ack" });
|
|
});
|
|
|
|
it("keeps unauthorized native commands on the plugin-bound claim path", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-native-unauthorized",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:11",
|
|
parentConversationId: "-1001234567890",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/tmp/plugin",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:-1001234567890",
|
|
To: "telegram:-1001234567890",
|
|
AccountId: "default",
|
|
MessageThreadId: 11,
|
|
ChatType: "group",
|
|
GroupSubject: "Dev",
|
|
Body: "/status",
|
|
RawBody: "/status",
|
|
CommandBody: "/status",
|
|
CommandSource: "native",
|
|
CommandAuthorized: false,
|
|
WasMentioned: false,
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
const claimCall = firstMockCall(
|
|
hookMocks.runner.runInboundClaimForPluginOutcome,
|
|
"plugin inbound claim",
|
|
);
|
|
expect(claimCall[0]).toBe("openclaw-codex-app-server");
|
|
expect(claimCall[1]).toMatchObject({
|
|
channel: "telegram",
|
|
content: "/status",
|
|
});
|
|
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
|
|
expect(claimContext.pluginBinding).toMatchObject({ bindingId: "binding-native-unauthorized" });
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps structured normal command turns on the plugin-bound claim path", async () => {
|
|
setNoAbort();
|
|
hookMocks.runner.hasHooks.mockImplementation(
|
|
((hookName?: string) =>
|
|
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
|
);
|
|
hookMocks.registry.plugins = [{ id: "openclaw-codex-app-server", status: "loaded" }];
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
|
status: "handled",
|
|
result: { handled: true },
|
|
});
|
|
hookMocks.runner.runInboundClaimForPluginOutcome.mockClear();
|
|
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
|
bindingId: "binding-structured-normal-turn",
|
|
targetSessionKey: "plugin-binding:codex:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-1001234567890:topic:11",
|
|
parentConversationId: "-1001234567890",
|
|
},
|
|
status: "active",
|
|
boundAt: 1710000000000,
|
|
metadata: {
|
|
pluginBindingOwner: "plugin",
|
|
pluginId: "openclaw-codex-app-server",
|
|
pluginRoot: "/tmp/plugin",
|
|
},
|
|
} satisfies SessionBindingRecord);
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async () => ({ text: "core reply" }) satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:-1001234567890",
|
|
To: "telegram:-1001234567890",
|
|
AccountId: "default",
|
|
MessageThreadId: 11,
|
|
ChatType: "group",
|
|
GroupSubject: "Dev",
|
|
Body: "through this",
|
|
RawBody: "through this",
|
|
CommandBody: "/think high through this",
|
|
CommandAuthorized: true,
|
|
CommandTurn: {
|
|
kind: "normal",
|
|
source: "message",
|
|
authorized: false,
|
|
body: "/think high through this",
|
|
},
|
|
WasMentioned: false,
|
|
});
|
|
|
|
await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
const claimCall = firstMockCall(
|
|
hookMocks.runner.runInboundClaimForPluginOutcome,
|
|
"plugin inbound claim",
|
|
);
|
|
expect(claimCall[0]).toBe("openclaw-codex-app-server");
|
|
expect(claimCall[1]).toMatchObject({
|
|
channel: "telegram",
|
|
content: "/think high through this",
|
|
});
|
|
const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } };
|
|
expect(claimContext.pluginBinding).toMatchObject({
|
|
bindingId: "binding-structured-normal-turn",
|
|
});
|
|
expect(replyResolver).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps message-tool-only source delivery private while still processing the turn", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const callbacks = {
|
|
partial: vi.fn(),
|
|
reasoning: vi.fn(),
|
|
assistantStart: vi.fn(),
|
|
blockQueued: vi.fn(),
|
|
toolStart: vi.fn(),
|
|
itemEvent: vi.fn(),
|
|
planUpdate: vi.fn(),
|
|
toolResult: vi.fn(),
|
|
typingStart: vi.fn(async () => {}),
|
|
};
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.suppressTyping).toBe(false);
|
|
await opts?.onReplyStart?.();
|
|
await opts?.onPartialReply?.({ text: "draft leak" });
|
|
await opts?.onReasoningStream?.({ text: "reasoning leak" });
|
|
await opts?.onAssistantMessageStart?.();
|
|
await opts?.onToolStart?.({ name: "lookup" });
|
|
await opts?.onItemEvent?.({ progressText: "working" });
|
|
await opts?.onPlanUpdate?.({ phase: "update", explanation: "planning" });
|
|
await opts?.onToolResult?.({ text: "tool output" });
|
|
await opts?.onBlockReply?.({ text: "streaming block" });
|
|
return { text: "final reply" } satisfies ReplyPayload;
|
|
});
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
onPartialReply: callbacks.partial,
|
|
onReasoningStream: callbacks.reasoning,
|
|
onAssistantMessageStart: callbacks.assistantStart,
|
|
onReplyStart: callbacks.typingStart,
|
|
onBlockReplyQueued: callbacks.blockQueued,
|
|
onToolStart: callbacks.toolStart,
|
|
onItemEvent: callbacks.itemEvent,
|
|
onPlanUpdate: callbacks.planUpdate,
|
|
onToolResult: callbacks.toolResult,
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
expect(callbacks.typingStart).toHaveBeenCalledTimes(1);
|
|
for (const [name, callback] of Object.entries(callbacks)) {
|
|
if (name === "typingStart") {
|
|
continue;
|
|
}
|
|
expect(callback).not.toHaveBeenCalled();
|
|
}
|
|
const replyDispatchCall = hookMocks.runner.runReplyDispatch.mock.calls.find(
|
|
([event]) =>
|
|
(event as { sourceReplyDeliveryMode?: unknown }).sourceReplyDeliveryMode ===
|
|
"message_tool_only",
|
|
);
|
|
const replyDispatchEvent = replyDispatchCall?.[0] as
|
|
| {
|
|
sendPolicy?: unknown;
|
|
sourceReplyDeliveryMode?: unknown;
|
|
suppressReplyLifecycle?: unknown;
|
|
suppressUserDelivery?: unknown;
|
|
}
|
|
| undefined;
|
|
expect(replyDispatchEvent?.suppressUserDelivery).toBe(true);
|
|
expect(replyDispatchEvent?.suppressReplyLifecycle).toBe(false);
|
|
expect(replyDispatchEvent?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(replyDispatchEvent?.sendPolicy).toBe("allow");
|
|
if (replyDispatchCall?.[1] === undefined) {
|
|
throw new Error("Expected reply dispatch metadata");
|
|
}
|
|
});
|
|
|
|
it("preserves hook-blocked metadata when source delivery is message-tool-only", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const blockedReply = setReplyPayloadMetadata(
|
|
{ text: "Your message could not be sent: blocked by policy-plugin", isError: true },
|
|
{ beforeAgentRunBlocked: true },
|
|
);
|
|
const replyResolver = vi.fn(async () => blockedReply satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
SessionKey: "test:session",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.beforeAgentRunBlocked).toBe(true);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers verbose tool progress in message-tool-only mode", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
verboseLevel: "on",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
await opts?.onToolResult?.({ text: "🛠️ Exec: echo post-restart" });
|
|
return { text: "NO_REPLY" } satisfies ReplyPayload;
|
|
});
|
|
const ctx = buildTestCtx({ SessionKey: "test:session", ChatType: "channel" });
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith(
|
|
expect.objectContaining({ text: "🛠️ Exec: echo post-restart" }),
|
|
);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers marked runtime failure notices in message-tool-only mode", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const failureNotice = setReplyPayloadMetadata(
|
|
{ text: "⚠️ You've reached your Codex subscription usage limit." },
|
|
{ deliverDespiteSourceReplySuppression: true },
|
|
);
|
|
const replyResolver = vi.fn(async () => failureNotice satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(failureNotice);
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("suppresses marked runtime failure notices for room events", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const failureNotice = setReplyPayloadMetadata(
|
|
{ text: "⚠️ You've reached your Codex subscription usage limit." },
|
|
{ deliverDespiteSourceReplySuppression: true },
|
|
);
|
|
const replyResolver = vi.fn(async () => failureNotice satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
ChatType: "group",
|
|
InboundEventKind: "room_event",
|
|
SessionKey: "test:session",
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendBlockReply).not.toHaveBeenCalled();
|
|
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers marked explicit command terminal replies in room events (#87107)", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const commandReply = setReplyPayloadMetadata(
|
|
{ text: "⚙️ Compacted (76k → 934 tokens)" },
|
|
{ deliverDespiteSourceReplySuppression: true },
|
|
);
|
|
const replyResolver = vi.fn(async () => commandReply satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
ChatType: "group",
|
|
InboundEventKind: "room_event",
|
|
SessionKey: "test:session",
|
|
CommandSource: "text",
|
|
CommandAuthorized: true,
|
|
CommandBody: "/compact",
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(commandReply);
|
|
});
|
|
|
|
it("delivers marked /compact reply in room event when CommandSource is undefined (#87107)", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const commandReply = setReplyPayloadMetadata(
|
|
{ text: "⚙️ Compacted (76k → 934 tokens)" },
|
|
{ deliverDespiteSourceReplySuppression: true },
|
|
);
|
|
const replyResolver = vi.fn(async () => commandReply satisfies ReplyPayload);
|
|
const ctx = buildTestCtx({
|
|
ChatType: "group",
|
|
InboundEventKind: "room_event",
|
|
SessionKey: "test:session",
|
|
CommandAuthorized: true,
|
|
CommandBody: "/compact",
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(commandReply);
|
|
});
|
|
|
|
it("mirrors internal source reply payloads into the active transcript", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const sourceReply = setReplyPayloadMetadata(
|
|
{ text: "message tool reply" },
|
|
{
|
|
deliverDespiteSourceReplySuppression: true,
|
|
sourceReplyTranscriptMirror: {
|
|
sessionKey: "agent:main",
|
|
agentId: "main",
|
|
text: "message tool reply",
|
|
idempotencyKey: "run-1:internal-source-reply:0",
|
|
},
|
|
},
|
|
);
|
|
const replyResolver = vi.fn(async () => sourceReply satisfies ReplyPayload);
|
|
transcriptMocks.appendAssistantMessageToSessionTranscript.mockClear();
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({ Provider: "webchat", Surface: "webchat", SessionKey: "agent:main" }),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(sourceReply);
|
|
expect(transcriptMocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main",
|
|
agentId: "main",
|
|
text: "message tool reply",
|
|
mediaUrls: undefined,
|
|
idempotencyKey: "run-1:internal-source-reply:0",
|
|
updateMode: "inline",
|
|
config: emptyConfig,
|
|
});
|
|
});
|
|
|
|
it("mirrors post-hook internal source reply payloads into the active transcript", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
dispatcher.appendBeforeDeliver?.((payload, info) => {
|
|
if (info.kind !== "final") {
|
|
return payload;
|
|
}
|
|
return setReplyPayloadMetadata(
|
|
{
|
|
...payload,
|
|
text: "redacted hook reply",
|
|
mediaUrl: undefined,
|
|
mediaUrls: ["https://example.com/redacted.png"],
|
|
},
|
|
getReplyPayloadMetadata(payload) ?? {},
|
|
);
|
|
});
|
|
const sourceReply = setReplyPayloadMetadata(
|
|
{ text: "secret message tool reply", mediaUrl: "https://example.com/secret.png" },
|
|
{
|
|
deliverDespiteSourceReplySuppression: true,
|
|
sourceReplyTranscriptMirror: {
|
|
sessionKey: "agent:main",
|
|
agentId: "main",
|
|
text: "secret message tool reply",
|
|
mediaUrls: ["https://example.com/secret.png"],
|
|
idempotencyKey: "run-1:internal-source-reply:rewritten",
|
|
},
|
|
},
|
|
);
|
|
const replyResolver = vi.fn(async () => sourceReply satisfies ReplyPayload);
|
|
transcriptMocks.appendAssistantMessageToSessionTranscript.mockClear();
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({ Provider: "webchat", Surface: "webchat", SessionKey: "agent:main" }),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(sourceReply);
|
|
expect(transcriptMocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith({
|
|
sessionKey: "agent:main",
|
|
agentId: "main",
|
|
text: "redacted hook reply",
|
|
mediaUrls: ["https://example.com/redacted.png"],
|
|
idempotencyKey: "run-1:internal-source-reply:rewritten",
|
|
updateMode: "inline",
|
|
config: emptyConfig,
|
|
});
|
|
});
|
|
|
|
it("does not mirror internal source replies cancelled by dispatcher hooks", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
dispatcher.getCancelledCounts = vi
|
|
.fn()
|
|
.mockReturnValueOnce({ tool: 0, block: 0, final: 0 })
|
|
.mockReturnValue({ tool: 0, block: 0, final: 1 });
|
|
dispatcher.waitForIdle = vi.fn(async () => {});
|
|
const sourceReply = setReplyPayloadMetadata(
|
|
{ text: "message tool reply" },
|
|
{
|
|
deliverDespiteSourceReplySuppression: true,
|
|
sourceReplyTranscriptMirror: {
|
|
sessionKey: "agent:main",
|
|
agentId: "main",
|
|
text: "message tool reply",
|
|
idempotencyKey: "run-1:internal-source-reply:0",
|
|
},
|
|
},
|
|
);
|
|
const replyResolver = vi.fn(async () => sourceReply satisfies ReplyPayload);
|
|
transcriptMocks.appendAssistantMessageToSessionTranscript.mockClear();
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({ Provider: "webchat", Surface: "webchat", SessionKey: "agent:main" }),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(sourceReply);
|
|
expect(dispatcher.waitForIdle).toHaveBeenCalledTimes(1);
|
|
expect(transcriptMocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps internal source reply metadata on TTS-cloned final payloads", async () => {
|
|
setNoAbort();
|
|
ttsMocks.state.synthesizeFinalAudio = true;
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const sourceReply = setReplyPayloadMetadata(
|
|
{ text: "message tool reply" },
|
|
{
|
|
deliverDespiteSourceReplySuppression: true,
|
|
sourceReplyTranscriptMirror: {
|
|
sessionKey: "agent:main",
|
|
agentId: "main",
|
|
text: "message tool reply",
|
|
idempotencyKey: "run-tts:internal-source-reply:0",
|
|
},
|
|
},
|
|
);
|
|
const replyResolver = vi.fn(async () => sourceReply satisfies ReplyPayload);
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({ Provider: "webchat", Surface: "webchat", SessionKey: "agent:main" }),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(result.queuedFinal).toBe(true);
|
|
const queuedPayload = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock
|
|
.calls[0]?.[0];
|
|
expect(queuedPayload).toMatchObject({
|
|
text: "message tool reply",
|
|
mediaUrl: "https://example.com/tts-synth.opus",
|
|
audioAsVoice: true,
|
|
});
|
|
expect(getReplyPayloadMetadata(queuedPayload)?.sourceReplyTranscriptMirror).toMatchObject({
|
|
sessionKey: "agent:main",
|
|
idempotencyKey: "run-tts:internal-source-reply:0",
|
|
});
|
|
});
|
|
|
|
it("does not deliver marked runtime failure notices when sendPolicy denies delivery", async () => {
|
|
setNoAbort();
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
sendPolicy: "deny",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(
|
|
async () =>
|
|
setReplyPayloadMetadata(
|
|
{ text: "⚠️ You've reached your Codex subscription usage limit." },
|
|
{ deliverDespiteSourceReplySuppression: true },
|
|
) satisfies ReplyPayload,
|
|
);
|
|
const ctx = buildTestCtx({ SessionKey: "test:session" });
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx,
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps opted-in group/channel final replies private when message-tool-only events miss the message tool", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(opts?.suppressTyping).toBe(false);
|
|
return { text: "final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
CommandSource: undefined,
|
|
SessionKey: "test:discord:channel:C1",
|
|
}),
|
|
cfg: {
|
|
messages: {
|
|
groupChat: { visibleReplies: "message_tool" },
|
|
},
|
|
},
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps same-provider group/channel final replies private in message-tool-only mode", async () => {
|
|
setNoAbort();
|
|
mocks.routeReply.mockClear();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
return { text: "final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
CommandSource: undefined,
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
OriginatingChannel: "discord",
|
|
OriginatingTo: "channel:C1",
|
|
SessionKey: "test:discord:channel:C1",
|
|
}),
|
|
cfg: {
|
|
messages: {
|
|
groupChat: { visibleReplies: "message_tool" },
|
|
},
|
|
},
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
expect(mocks.routeReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps ambient room-event group/channel finals private without a message tool send", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
return { text: "ambient final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
InboundEventKind: "room_event",
|
|
SessionKey: "test:discord:channel:C1",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("delivers internal WebChat room-event final replies automatically", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible webchat reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
InboundEventKind: "room_event",
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
SessionKey: "agent:forge:webchat:forge-main",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(result.sourceReplyDeliveryMode).toBeUndefined();
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible webchat reply");
|
|
});
|
|
|
|
it("preserves configured message-tool delivery for internal WebChat direct replies", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
return { text: "private webchat final" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
SessionKey: "agent:forge:webchat:forge-main",
|
|
}),
|
|
cfg: { messages: { visibleReplies: "message_tool" } } as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(result.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps default direct source delivery automatic", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible direct reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible direct reply");
|
|
});
|
|
|
|
it("keeps Codex direct source delivery message-tool-only when config is unset", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: () => ({ supported: true, priority: 100 }),
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
agentHarnessId: "codex",
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
return { text: "private final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
SessionKey: "agent:main:main",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses Codex direct source delivery defaults before a session entry exists", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: () => ({ supported: true, priority: 100 }),
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = undefined;
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
return { text: "private first reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses channel model overrides before Codex first-turn direct source delivery defaults", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "codex"
|
|
? { supported: true, priority: 100 }
|
|
: { supported: false, reason: "codex provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = undefined;
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible channel-model reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
}),
|
|
cfg: {
|
|
channels: {
|
|
modelByChannel: {
|
|
telegram: {
|
|
"*": "anthropic/claude-sonnet-4.6",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible channel-model reply");
|
|
});
|
|
|
|
it("uses channel model overrides before cached Codex runtime defaults", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "codex"
|
|
? { supported: true, priority: 100 }
|
|
: { supported: false, reason: "codex provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
agentHarnessId: "codex",
|
|
modelProvider: "codex",
|
|
model: "gpt-5.5",
|
|
channel: "telegram",
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible existing-channel-model reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
}),
|
|
cfg: {
|
|
channels: {
|
|
modelByChannel: {
|
|
telegram: {
|
|
"*": "anthropic/claude-sonnet-4.6",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible existing-channel-model reply");
|
|
});
|
|
|
|
it("uses configured defaults before cached Codex runtime metadata", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "codex"
|
|
? { supported: true, priority: 100 }
|
|
: { supported: false, reason: "codex provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
agentHarnessId: "codex",
|
|
modelProvider: "codex",
|
|
model: "gpt-5.5",
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible configured-default reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
}),
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "anthropic/claude-sonnet-4.6" },
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible configured-default reply");
|
|
});
|
|
|
|
it("lets config restore automatic Codex direct source delivery", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: () => ({ supported: true, priority: 100 }),
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
agentHarnessId: "codex",
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
SessionKey: "agent:main:main",
|
|
}),
|
|
cfg: { messages: { visibleReplies: "automatic" } } as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible final reply");
|
|
});
|
|
|
|
it("honors model overrides before cached Codex direct source delivery defaults", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "codex"
|
|
? { supported: true, priority: 100 }
|
|
: { supported: false, reason: "codex provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
agentHarnessId: "codex",
|
|
agentRuntimeOverride: "codex",
|
|
providerOverride: "anthropic",
|
|
modelOverride: "claude-sonnet-4.6",
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible switched-model reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
SessionKey: "agent:main:main",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible switched-model reply");
|
|
});
|
|
|
|
it("honors parent model overrides before Codex direct source delivery defaults", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "codex"
|
|
? { supported: true, priority: 100 }
|
|
: { supported: false, reason: "codex provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
const parentSessionKey = "agent:main:telegram:direct:U1";
|
|
const childSessionKey = `${parentSessionKey}:thread:topic-1`;
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "child",
|
|
updatedAt: 0,
|
|
agentHarnessId: "codex",
|
|
parentSessionKey,
|
|
sendPolicy: "allow",
|
|
};
|
|
sessionStoreMocks.loadSessionStore.mockReturnValueOnce({
|
|
[parentSessionKey]: {
|
|
sessionId: "parent",
|
|
updatedAt: 0,
|
|
providerOverride: "anthropic",
|
|
modelOverride: "claude-sonnet-4.6",
|
|
},
|
|
});
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible parent-model reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
ModelParentSessionKey: parentSessionKey,
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: childSessionKey,
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible parent-model reply");
|
|
});
|
|
|
|
it("honors heartbeat model overrides before Codex direct source delivery defaults", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "codex"
|
|
? { supported: true, priority: 100 }
|
|
: { supported: false, reason: "codex provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible heartbeat-model reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
SessionKey: "agent:main:telegram:direct:U1",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyOptions: {
|
|
isHeartbeat: true,
|
|
heartbeatModelOverride: "anthropic/claude-sonnet-4.6",
|
|
},
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible heartbeat-model reply");
|
|
});
|
|
|
|
it("preserves non-Codex harness direct source delivery defaults", async () => {
|
|
setNoAbort();
|
|
registerAgentHarness({
|
|
id: "custom",
|
|
label: "Custom",
|
|
deliveryDefaults: { sourceVisibleReplies: "message_tool" },
|
|
supports: (ctx) =>
|
|
ctx.provider === "custom"
|
|
? { supported: true, priority: 200 }
|
|
: { supported: false, reason: "custom provider only" },
|
|
runAttempt: vi.fn(async () => ({}) as never),
|
|
});
|
|
sessionStoreMocks.currentEntry = {
|
|
sessionId: "s1",
|
|
updatedAt: 0,
|
|
agentHarnessId: "custom",
|
|
sendPolicy: "allow",
|
|
};
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
return { text: "private final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "direct",
|
|
CommandSource: undefined,
|
|
Provider: "custom",
|
|
SessionKey: "agent:main:main",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(false);
|
|
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to automatic group/channel delivery when the message tool is unavailable", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "visible fallback" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
SessionKey: "test:discord:channel:C1",
|
|
}),
|
|
cfg: {
|
|
messages: {
|
|
groupChat: { visibleReplies: "message_tool" },
|
|
},
|
|
tools: { allow: ["read"] },
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("visible fallback");
|
|
});
|
|
|
|
it("falls back to automatic group/channel delivery when group tools remove the message tool", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "group policy fallback" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
From: "discord:channel:C1",
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
SessionKey: "agent:main:discord:channel:C1",
|
|
}),
|
|
cfg: {
|
|
messages: {
|
|
groupChat: { visibleReplies: "message_tool" },
|
|
},
|
|
channels: {
|
|
discord: {
|
|
groups: {
|
|
C1: { tools: { allow: ["read"] } },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("group policy fallback");
|
|
});
|
|
|
|
it("falls back when a channel precomputed message-tool-only delivery but the message tool is unavailable", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "requested fallback" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "channel",
|
|
SessionKey: "test:discord:channel:C1",
|
|
}),
|
|
cfg: { tools: { allow: ["read"] } } as OpenClawConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
replyOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
},
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("requested fallback");
|
|
});
|
|
|
|
it("keeps native command replies visible in group/channel events", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
expect(opts?.suppressTyping).toBe(false);
|
|
return { text: "status reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "group",
|
|
CommandSource: "native",
|
|
CommandAuthorized: true,
|
|
WasMentioned: true,
|
|
SessionKey: "test:telegram:group:G1",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("status reply");
|
|
});
|
|
|
|
it("keeps default group/channel source delivery automatic", async () => {
|
|
setNoAbort();
|
|
const dispatcher = createDispatcher();
|
|
const replyResolver = vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
|
|
expect(opts?.sourceReplyDeliveryMode).toBe("automatic");
|
|
return { text: "final reply" } satisfies ReplyPayload;
|
|
});
|
|
|
|
const result = await dispatchReplyFromConfig({
|
|
ctx: buildTestCtx({
|
|
ChatType: "group",
|
|
WasMentioned: true,
|
|
SessionKey: "test:telegram:group:G1",
|
|
}),
|
|
cfg: emptyConfig,
|
|
dispatcher,
|
|
replyResolver,
|
|
});
|
|
|
|
expect(replyResolver).toHaveBeenCalledTimes(1);
|
|
expect(result.queuedFinal).toBe(true);
|
|
expect(firstFinalReplyPayload(dispatcher)?.text).toBe("final reply");
|
|
});
|
|
});
|