mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
da44d52ac6
* feat(gateway): add transient question runtime (question.* methods + broadcasts) * feat(agents): add blocking ask_user question tool with chat prompt delivery and text-reply claim * feat(ui): interactive in-thread question cards for ask_user * feat(channels): native tap-to-answer buttons for ask_user on Telegram, Discord, and Slack * feat(ui): unify codex and gateway question cards with interactive gateway answering * refactor(agents): collapse ask_user pending state to one registry; docs for ask_user * fix(agents): include ask_user in normal gateway runs; add question-flow control-ui e2e * test(ui): avoid credential-shaped fixture in question card test * refactor(ui): reorder stream-group context keys * fix(gateway,ui): validate question answers at resolve; reject secret/duplicate-label questions; UI retry and reconnect hardening * fix(gateway,agents): canonicalize accepted option answers; bound ask_user option labels to 64 chars * chore(ci): prune unused question exports, allowlist mobile question events, fix discord lint * chore(ci): regenerate protocol/i18n/docs/tool-display artifacts for question surface * fix(protocol): flatten QuestionRecord for native codegen; drop TS-only alias from schema registry * chore(android): regenerate ask-user localization resources * docs: regenerate docs map after rebase * fix(ci): avoid stale read-only dependency disks * test: remove stale reef lint suppression ratchet * fix(ci): keep source locale drift advisory in release gates * fix(ci): scope locale advisory handling to parity check
3886 lines
128 KiB
TypeScript
3886 lines
128 KiB
TypeScript
// Message tool tests cover channel action discovery, secret scoping, and
|
|
// outbound message execution context.
|
|
import { Type } from "typebox";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ChannelMessageAdapterShape } from "../../channels/message/types.js";
|
|
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
|
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
|
import {
|
|
mintMessageActionTurnCapability,
|
|
revokeMessageActionTurnCapability,
|
|
} from "../../gateway/message-action-turn-capability.js";
|
|
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
|
import { resetDiagnosticSessionStateForTest } from "../../logging/diagnostic-session-state.js";
|
|
import {
|
|
MESSAGE_TOOL_DELIVERY_HINTS,
|
|
MESSAGE_TOOL_ONLY_DELIVERY_HINT,
|
|
} from "../../plugin-sdk/message-tool-delivery-hints.js";
|
|
import { wrapToolWithBeforeToolCallHook } from "../agent-tools.before-tool-call.js";
|
|
type CreateMessageTool = typeof import("./message-tool.js").createMessageTool;
|
|
type CreateOpenClawTools = typeof import("../openclaw-tools.js").createOpenClawTools;
|
|
type ResetPluginRuntimeStateForTest =
|
|
typeof import("../../plugins/runtime.js").resetPluginRuntimeStateForTest;
|
|
type SetActivePluginRegistry = typeof import("../../plugins/runtime.js").setActivePluginRegistry;
|
|
type CreateTestRegistry = typeof import("../../test-utils/channel-plugins.js").createTestRegistry;
|
|
|
|
const ROOM_EVENT_DELIVERY_HINT = MESSAGE_TOOL_DELIVERY_HINTS[3];
|
|
const CRITICAL_THRESHOLD = 20;
|
|
|
|
let createMessageTool: CreateMessageTool;
|
|
let createOpenClawTools: CreateOpenClawTools;
|
|
let resetPluginRuntimeStateForTest: ResetPluginRuntimeStateForTest;
|
|
let setActivePluginRegistry: SetActivePluginRegistry;
|
|
let createTestRegistry: CreateTestRegistry;
|
|
|
|
type DescribeMessageTool = NonNullable<
|
|
NonNullable<ChannelPlugin["actions"]>["describeMessageTool"]
|
|
>;
|
|
type MessageToolDiscoveryContext = Parameters<DescribeMessageTool>[0];
|
|
type MessageToolSchema = NonNullable<ReturnType<DescribeMessageTool>>["schema"];
|
|
|
|
function createTelegramPollExtraToolSchemas() {
|
|
return {
|
|
pollDurationSeconds: Type.Optional(Type.Number()),
|
|
pollAnonymous: Type.Optional(Type.Boolean()),
|
|
pollPublic: Type.Optional(Type.Boolean()),
|
|
};
|
|
}
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
runMessageAction: vi.fn(),
|
|
getRuntimeConfig: vi.fn(() => ({})),
|
|
resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({
|
|
resolvedConfig: config,
|
|
diagnostics: [],
|
|
})),
|
|
getScopedChannelsCommandSecretTargets: vi.fn(
|
|
({
|
|
config,
|
|
channel,
|
|
accountId,
|
|
}: {
|
|
config?: { channels?: Record<string, unknown> };
|
|
channel?: string | null;
|
|
accountId?: string | null;
|
|
}) => {
|
|
const allowedPaths = new Set<string>();
|
|
const targetIds = new Set<string>();
|
|
const scopedChannel = channel?.trim();
|
|
const scopedAccountId = accountId?.trim();
|
|
const scopedConfig =
|
|
scopedChannel && config?.channels && typeof config.channels[scopedChannel] === "object"
|
|
? (config.channels[scopedChannel] as Record<string, unknown>)
|
|
: null;
|
|
if (!scopedChannel || !scopedConfig) {
|
|
return { targetIds };
|
|
}
|
|
|
|
const maybeCollectSecretPath = (path: string, value: unknown) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
if (typeof record.source === "string" && typeof record.id === "string") {
|
|
targetIds.add(path);
|
|
allowedPaths.add(path);
|
|
}
|
|
};
|
|
|
|
maybeCollectSecretPath(`channels.${scopedChannel}.token`, scopedConfig.token);
|
|
maybeCollectSecretPath(`channels.${scopedChannel}.botToken`, scopedConfig.botToken);
|
|
maybeCollectSecretPath(`channels.${scopedChannel}.appPassword`, scopedConfig.appPassword);
|
|
if (scopedAccountId) {
|
|
const accountRecord =
|
|
scopedConfig.accounts &&
|
|
typeof scopedConfig.accounts === "object" &&
|
|
!Array.isArray(scopedConfig.accounts) &&
|
|
typeof (scopedConfig.accounts as Record<string, unknown>)[scopedAccountId] === "object"
|
|
? ((scopedConfig.accounts as Record<string, unknown>)[scopedAccountId] as Record<
|
|
string,
|
|
unknown
|
|
>)
|
|
: null;
|
|
if (accountRecord) {
|
|
maybeCollectSecretPath(
|
|
`channels.${scopedChannel}.accounts.${scopedAccountId}.token`,
|
|
accountRecord.token,
|
|
);
|
|
maybeCollectSecretPath(
|
|
`channels.${scopedChannel}.accounts.${scopedAccountId}.botToken`,
|
|
accountRecord.botToken,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
targetIds,
|
|
...(allowedPaths.size > 0 ? { allowedPaths } : {}),
|
|
};
|
|
},
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../channels/plugins/bundled.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../../channels/plugins/bundled.js")>(
|
|
"../../channels/plugins/bundled.js",
|
|
);
|
|
// This unit suite installs minimal loaded plugins when it exercises channel actions.
|
|
// Bundled source entry loading belongs to the loader integration suites.
|
|
return {
|
|
...actual,
|
|
getBundledChannelPlugin: vi.fn(() => undefined),
|
|
getBundledChannelSetupPlugin: vi.fn(() => undefined),
|
|
};
|
|
});
|
|
|
|
type RunMessageActionInput = {
|
|
agentId?: string;
|
|
cfg?: unknown;
|
|
conversationReadOrigin?: "delegated" | "direct-operator";
|
|
defaultAccountId?: string;
|
|
gateway?: {
|
|
timeoutMs?: unknown;
|
|
terminalSourceReplyReceiptOwner?: "caller";
|
|
resolveAgentRuntimeIdentityToken?: (context?: {
|
|
sourceReplyFinal?: boolean;
|
|
sourceReplyToolCallId?: string;
|
|
}) => Promise<string | undefined>;
|
|
};
|
|
params?: Record<string, unknown>;
|
|
requesterAccountId?: string;
|
|
requesterSenderId?: string;
|
|
messageActionAuthorization?: {
|
|
requesterAccountId?: string;
|
|
requesterSenderId?: string;
|
|
toolContext?: RunMessageActionInput["toolContext"];
|
|
};
|
|
sandboxRoot?: string;
|
|
sessionKey?: string;
|
|
sourceReplyDeliveryMode?: string;
|
|
sourceReplyFinal?: boolean;
|
|
sourceReplyToolCallId?: string;
|
|
inboundAudio?: boolean;
|
|
toolContext?: {
|
|
currentChannelId?: string;
|
|
currentChatType?: string;
|
|
currentMessagingTarget?: string;
|
|
currentChannelProvider?: string;
|
|
currentThreadTs?: string;
|
|
replyToMode?: string;
|
|
};
|
|
};
|
|
|
|
function firstRunMessageActionInput(): RunMessageActionInput | undefined {
|
|
return mocks.runMessageAction.mock.calls[0]?.[0] as RunMessageActionInput | undefined;
|
|
}
|
|
|
|
function lastRunMessageActionInput(): RunMessageActionInput | undefined {
|
|
return mocks.runMessageAction.mock.calls.at(-1)?.[0] as RunMessageActionInput | undefined;
|
|
}
|
|
|
|
function latestSecretResolveCall(): {
|
|
allowedPaths?: Set<string>;
|
|
config?: unknown;
|
|
targetIds?: Set<string>;
|
|
} {
|
|
const calls = mocks.resolveCommandSecretRefsViaGateway.mock.calls;
|
|
const call = calls[calls.length - 1];
|
|
if (!call) {
|
|
throw new Error("expected secret resolution call");
|
|
}
|
|
// Secret resolution is scoped to the active channel/account; tests inspect
|
|
// the exact target set to avoid broad credential reads.
|
|
return call[0] as {
|
|
allowedPaths?: Set<string>;
|
|
config?: unknown;
|
|
targetIds?: Set<string>;
|
|
};
|
|
}
|
|
|
|
const openClawToolsFactoryMocks = vi.hoisted(() => {
|
|
const tool = (name: string) => ({
|
|
name,
|
|
displaySummary: `${name} test stub`,
|
|
description: `${name} test stub`,
|
|
parameters: { type: "object", properties: {} },
|
|
execute: vi.fn(async () => ({ type: "json", data: { ok: true } })),
|
|
});
|
|
return {
|
|
tool,
|
|
};
|
|
});
|
|
|
|
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import("../../infra/outbound/message-action-runner.js")
|
|
>("../../infra/outbound/message-action-runner.js");
|
|
return {
|
|
...actual,
|
|
runMessageAction: mocks.runMessageAction,
|
|
};
|
|
});
|
|
|
|
vi.mock("../../config/config.js", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
|
return {
|
|
...actual,
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
};
|
|
});
|
|
|
|
vi.mock("../../cli/command-secret-gateway.js", () => ({
|
|
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
|
}));
|
|
|
|
vi.mock("../../cli/command-secret-targets.js", () => ({
|
|
getScopedChannelsCommandSecretTargets: mocks.getScopedChannelsCommandSecretTargets,
|
|
}));
|
|
|
|
vi.mock("../../channels/plugins/message-tool-api.js", () => ({
|
|
resolveBundledChannelMessageToolDiscoveryAdapter: () => ({
|
|
describeMessageTool: () => ({ actions: ["send"], capabilities: [] }),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./agents-list-tool.js", () => ({
|
|
createAgentsListTool: () => openClawToolsFactoryMocks.tool("agents"),
|
|
}));
|
|
vi.mock("./cron-tool.js", () => ({
|
|
createCronTool: () => openClawToolsFactoryMocks.tool("cron"),
|
|
}));
|
|
vi.mock("./gateway-tool.js", () => ({
|
|
createGatewayTool: () => openClawToolsFactoryMocks.tool("gateway"),
|
|
}));
|
|
vi.mock("./heartbeat-response-tool.js", () => ({
|
|
createHeartbeatResponseTool: () => openClawToolsFactoryMocks.tool("heartbeat_response"),
|
|
}));
|
|
vi.mock("./image-generate-tool.js", () => ({
|
|
createImageGenerateTool: () => null,
|
|
}));
|
|
vi.mock("./image-tool.js", () => ({
|
|
createImageTool: () => null,
|
|
}));
|
|
vi.mock("./manifest-capability-availability.js", () => ({
|
|
hasSnapshotCapabilityAvailability: () => false,
|
|
hasSnapshotProviderEnvAvailability: () => false,
|
|
loadCapabilityMetadataSnapshot: () => ({ index: {}, plugins: [] }),
|
|
}));
|
|
vi.mock("./music-generate-tool.js", () => ({
|
|
createMusicGenerateTool: () => null,
|
|
}));
|
|
vi.mock("./nodes-tool.js", () => ({
|
|
createNodesTool: () => openClawToolsFactoryMocks.tool("nodes"),
|
|
}));
|
|
vi.mock("./pdf-tool.js", () => ({
|
|
createPdfTool: () => null,
|
|
}));
|
|
vi.mock("./session-status-tool.js", () => ({
|
|
createSessionStatusTool: () => openClawToolsFactoryMocks.tool("session_status"),
|
|
}));
|
|
vi.mock("./sessions-history-tool.js", () => ({
|
|
createSessionsHistoryTool: () => openClawToolsFactoryMocks.tool("sessions_history"),
|
|
}));
|
|
vi.mock("./sessions-list-tool.js", () => ({
|
|
createSessionsListTool: () => openClawToolsFactoryMocks.tool("sessions_list"),
|
|
}));
|
|
vi.mock("./sessions-send-tool.js", () => ({
|
|
createSessionsSendTool: () => openClawToolsFactoryMocks.tool("sessions_send"),
|
|
}));
|
|
vi.mock("./sessions-spawn-tool.js", () => ({
|
|
createSessionsSpawnTool: () => openClawToolsFactoryMocks.tool("sessions_spawn"),
|
|
}));
|
|
vi.mock("./sessions-yield-tool.js", () => ({
|
|
createSessionsYieldTool: () => openClawToolsFactoryMocks.tool("sessions_yield"),
|
|
}));
|
|
vi.mock("./subagents-tool.js", () => ({
|
|
createSubagentsTool: () => openClawToolsFactoryMocks.tool("subagents"),
|
|
}));
|
|
vi.mock("./tts-tool.js", () => ({
|
|
createTtsTool: () => openClawToolsFactoryMocks.tool("tts"),
|
|
}));
|
|
vi.mock("./update-plan-tool.js", () => ({
|
|
createUpdatePlanTool: () => openClawToolsFactoryMocks.tool("update_plan"),
|
|
}));
|
|
vi.mock("./video-generate-tool.js", () => ({
|
|
createVideoGenerateTool: () => null,
|
|
}));
|
|
vi.mock("./web-tools.js", () => ({
|
|
createWebFetchTool: () => openClawToolsFactoryMocks.tool("web_fetch"),
|
|
createWebSearchTool: () => openClawToolsFactoryMocks.tool("web_search"),
|
|
}));
|
|
|
|
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
|
|
mocks.runMessageAction.mockClear();
|
|
mocks.runMessageAction.mockResolvedValue({
|
|
kind: "send",
|
|
action: "send",
|
|
channel: overrides.channel ?? "telegram",
|
|
to: overrides.to ?? "telegram:123",
|
|
handledBy: "plugin",
|
|
payload: {},
|
|
dryRun: true,
|
|
} satisfies MessageActionRunResult);
|
|
}
|
|
|
|
function getToolProperties(tool: ReturnType<CreateMessageTool>) {
|
|
return (tool.parameters as { properties?: Record<string, unknown> }).properties ?? {};
|
|
}
|
|
|
|
function getActionEnum(properties: Record<string, unknown>) {
|
|
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
|
|
}
|
|
|
|
function expectStringSchema(
|
|
schema: unknown,
|
|
expected?: {
|
|
description?: string;
|
|
},
|
|
) {
|
|
if (!schema || typeof schema !== "object") {
|
|
throw new Error("Expected string schema");
|
|
}
|
|
const record = schema as Record<string, unknown>;
|
|
expect(record.type).toBe("string");
|
|
if (expected?.description) {
|
|
expect(record.description).toBe(expected.description);
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } =
|
|
await import("../../plugins/runtime.js"));
|
|
({ createTestRegistry } = await import("../../test-utils/channel-plugins.js"));
|
|
({ createMessageTool } = await import("./message-tool.js"));
|
|
({ createOpenClawTools } = await import("../openclaw-tools.js"));
|
|
});
|
|
|
|
const mintedTurnCapabilities: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
resetPluginRuntimeStateForTest();
|
|
resetDiagnosticSessionStateForTest();
|
|
mocks.runMessageAction.mockReset();
|
|
mocks.getRuntimeConfig.mockReset().mockReturnValue({});
|
|
mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
|
|
resolvedConfig: config,
|
|
diagnostics: [],
|
|
}));
|
|
mocks.getScopedChannelsCommandSecretTargets.mockClear();
|
|
setActivePluginRegistry(createTestRegistry([]));
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const token of mintedTurnCapabilities.splice(0)) {
|
|
revokeMessageActionTurnCapability(token);
|
|
}
|
|
});
|
|
|
|
function createChannelPlugin(params: {
|
|
id: string;
|
|
label: string;
|
|
docsPath: string;
|
|
blurb: string;
|
|
aliases?: string[];
|
|
actions?: ChannelMessageActionName[];
|
|
capabilities?: readonly ChannelMessageCapability[];
|
|
toolSchema?: MessageToolSchema | ((params: MessageToolDiscoveryContext) => MessageToolSchema);
|
|
describeMessageTool?: DescribeMessageTool;
|
|
messageActionTargetAliases?: NonNullable<ChannelPlugin["actions"]>["messageActionTargetAliases"];
|
|
message?: ChannelMessageAdapterShape;
|
|
messaging?: ChannelPlugin["messaging"];
|
|
}): ChannelPlugin {
|
|
return {
|
|
id: params.id as ChannelPlugin["id"],
|
|
meta: {
|
|
id: params.id as ChannelPlugin["id"],
|
|
label: params.label,
|
|
selectionLabel: params.label,
|
|
docsPath: params.docsPath,
|
|
blurb: params.blurb,
|
|
aliases: params.aliases,
|
|
},
|
|
capabilities: { chatTypes: ["direct", "group"], media: true },
|
|
config: {
|
|
listAccountIds: () => ["default"],
|
|
resolveAccount: () => ({}),
|
|
},
|
|
...(params.message ? { message: params.message } : {}),
|
|
...(params.messaging ? { messaging: params.messaging } : {}),
|
|
actions: {
|
|
describeMessageTool:
|
|
params.describeMessageTool ??
|
|
((ctx) => {
|
|
const schema =
|
|
typeof params.toolSchema === "function" ? params.toolSchema(ctx) : params.toolSchema;
|
|
return {
|
|
actions: params.actions ?? [],
|
|
capabilities: params.capabilities,
|
|
...(schema ? { schema } : {}),
|
|
};
|
|
}),
|
|
messageActionTargetAliases: params.messageActionTargetAliases,
|
|
},
|
|
};
|
|
}
|
|
|
|
function registerMessagingPlugin(id: string, messaging: NonNullable<ChannelPlugin["messaging"]>) {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: id,
|
|
source: "test",
|
|
plugin: createChannelPlugin({
|
|
id,
|
|
label: id,
|
|
docsPath: `/channels/${id}`,
|
|
blurb: "Test channel",
|
|
messaging,
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
async function executeSend(params: {
|
|
action: Record<string, unknown>;
|
|
toolOptions?: Partial<Parameters<typeof createMessageTool>[0]>;
|
|
toolCallId?: string;
|
|
}) {
|
|
return (await executeSendWithResult(params)).call;
|
|
}
|
|
|
|
async function executeSendWithResult(params: {
|
|
action: Record<string, unknown>;
|
|
toolOptions?: Partial<Parameters<typeof createMessageTool>[0]>;
|
|
toolCallId?: string;
|
|
}) {
|
|
const { config, getRuntimeConfig, ...toolOptions } = params.toolOptions ?? {};
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: getRuntimeConfig ?? (config ? () => config : mocks.getRuntimeConfig),
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
...toolOptions,
|
|
});
|
|
const result = await tool.execute(params.toolCallId ?? "1", {
|
|
action: "send",
|
|
...params.action,
|
|
});
|
|
return { call: lastRunMessageActionInput(), result };
|
|
}
|
|
|
|
describe("message tool gateway timeout", () => {
|
|
it("does not advertise the Codex-only final delivery control", () => {
|
|
expect(getToolProperties(createMessageTool())).not.toHaveProperty("final");
|
|
});
|
|
|
|
it("advertises timeoutMs as a positive integer", () => {
|
|
const tool = createMessageTool();
|
|
expect(getToolProperties(tool).timeoutMs).toMatchObject({ type: "integer", minimum: 1 });
|
|
});
|
|
|
|
it("advertises shared poll duration as a positive integer", () => {
|
|
const tool = createMessageTool();
|
|
expect(getToolProperties(tool).pollDurationHours).toMatchObject({
|
|
type: "integer",
|
|
minimum: 1,
|
|
});
|
|
});
|
|
|
|
it("advertises shared action numeric params with runtime integer bounds", () => {
|
|
const properties = getToolProperties(createMessageTool());
|
|
|
|
for (const name of ["limit", "pageSize", "autoArchiveMin"]) {
|
|
expect(properties[name]).toMatchObject({ type: "integer", minimum: 1 });
|
|
}
|
|
for (const name of ["durationMin", "position", "rateLimitPerUser"]) {
|
|
expect(properties[name]).toMatchObject({ type: "integer", minimum: 0 });
|
|
}
|
|
expect(properties.deleteDays).toMatchObject({
|
|
type: "integer",
|
|
minimum: 0,
|
|
maximum: 7,
|
|
});
|
|
expect(properties.channelType).toMatchObject({ type: "integer", minimum: 0 });
|
|
expect(properties.pollOptionIndex).toMatchObject({ type: "integer", minimum: 1 });
|
|
expect(properties.pollOptionIndexes).toMatchObject({
|
|
type: "array",
|
|
items: { type: "integer", minimum: 1 },
|
|
});
|
|
});
|
|
|
|
it.each([-1, 1.5, "fast"])(
|
|
"rejects invalid timeoutMs value %s before dispatch",
|
|
async (timeoutMs) => {
|
|
mockSendResult();
|
|
const tool = createMessageTool({
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await expect(
|
|
tool.execute("1", {
|
|
action: "send",
|
|
target: "telegram:123",
|
|
message: "hi",
|
|
timeoutMs,
|
|
}),
|
|
).rejects.toThrow("timeoutMs must be a positive integer");
|
|
expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("accepts string timeoutMs values through the shared numeric reader", async () => {
|
|
mockSendResult();
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
message: "hi",
|
|
timeoutMs: "5000",
|
|
},
|
|
});
|
|
|
|
expect(call?.gateway?.timeoutMs).toBe(5000);
|
|
});
|
|
});
|
|
|
|
describe("poll vote echo guard", () => {
|
|
const currentChat = "iMessage;-;+15550001111";
|
|
let sessionKeyCounter = 0;
|
|
|
|
// The echo record is session-scoped so it survives the run boundary between a
|
|
// vote and the follow-up text. Give each tool a unique session key so tests
|
|
// stay isolated; a shared key would cross-contaminate via the module map.
|
|
function createPollVoteTool(votedOption = "Blue", agentSessionKey?: string) {
|
|
const sessionKey = agentSessionKey ?? `agent:test:imessage:direct:s${(sessionKeyCounter += 1)}`;
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "imessage",
|
|
source: "test",
|
|
plugin: createChannelPlugin({
|
|
id: "imessage",
|
|
label: "iMessage",
|
|
docsPath: "/channels/imessage",
|
|
blurb: "iMessage test plugin",
|
|
actions: ["poll-vote"],
|
|
messageActionTargetAliases: {
|
|
"poll-vote": {
|
|
aliases: ["chatGuid"],
|
|
deliveryTargetAliases: ["chatGuid"],
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
]),
|
|
);
|
|
mocks.runMessageAction.mockImplementation(async ({ action }: { action: string }) =>
|
|
action === "poll-vote"
|
|
? ({
|
|
kind: "action",
|
|
channel: "imessage",
|
|
action: "poll-vote",
|
|
handledBy: "plugin",
|
|
payload: {},
|
|
toolResult: {
|
|
content: [{ type: "text", text: "vote cast" }],
|
|
details: { pollVotedOption: votedOption },
|
|
},
|
|
dryRun: false,
|
|
} as MessageActionRunResult)
|
|
: ({
|
|
kind: "send",
|
|
channel: "imessage",
|
|
action: "send",
|
|
to: currentChat,
|
|
handledBy: "plugin",
|
|
payload: {},
|
|
dryRun: false,
|
|
} as MessageActionRunResult),
|
|
);
|
|
return createMessageTool({
|
|
currentChannelProvider: "imessage",
|
|
currentChannelId: currentChat,
|
|
agentAccountId: "primary",
|
|
agentSessionKey: sessionKey,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
}
|
|
|
|
async function castBlueVote(
|
|
tool: ReturnType<CreateMessageTool>,
|
|
overrides: Record<string, unknown> = {},
|
|
) {
|
|
await tool.execute("vote", {
|
|
action: "poll-vote",
|
|
channel: "imessage",
|
|
pollId: "poll-guid",
|
|
pollOptionIndex: 2,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
it("suppresses the first same-route restatement", async () => {
|
|
const tool = createPollVoteTool();
|
|
await castBlueVote(tool);
|
|
|
|
const result = await tool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "🦞 Blue.",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({ status: "suppressed", reason: "poll_vote_echo" });
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses an echo that lands in a later run (new tool instance, same session)", async () => {
|
|
// The live failure: a native poll and its comment arrive as separate inbound
|
|
// messages, so the vote and the restatement run in different agent turns with
|
|
// fresh tool instances. A session-scoped record must still catch the reply.
|
|
const sessionKey = "agent:test:imessage:direct:cross-run";
|
|
const voteTool = createPollVoteTool("Black", sessionKey);
|
|
await castBlueVote(voteTool);
|
|
|
|
const nextRunTool = createPollVoteTool("Black", sessionKey);
|
|
const result = await nextRunTool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "🦞 Black.",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({ status: "suppressed", reason: "poll_vote_echo" });
|
|
});
|
|
|
|
it("does not suppress a later-run echo from a different conversation", async () => {
|
|
const voteTool = createPollVoteTool("Black", "agent:test:imessage:direct:convo-a");
|
|
await castBlueVote(voteTool);
|
|
const otherTool = createPollVoteTool("Black", "agent:test:imessage:direct:convo-b");
|
|
await otherTool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "🦞 Black.",
|
|
});
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("suppresses an emoji-suffixed option echoed with a leading emoji", async () => {
|
|
// Live regression: iMessage poll options carry a trailing emoji
|
|
// ("Lobster 🦞 ") while the agent echoes a leading one ("🦞 Lobster.").
|
|
// A leading-only emoji strip left "lobster 🦞" != "lobster" and leaked.
|
|
const tool = createPollVoteTool("Lobster 🦞 ");
|
|
await castBlueVote(tool);
|
|
|
|
const result = await tool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "🦞 Lobster.",
|
|
});
|
|
|
|
expect(result.details).toMatchObject({ status: "suppressed", reason: "poll_vote_echo" });
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not suppress a different keycap option with the same words", async () => {
|
|
const tool = createPollVoteTool("Option 1️⃣");
|
|
await castBlueVote(tool);
|
|
|
|
const result = await tool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "2️⃣ Option.",
|
|
});
|
|
|
|
expect(result.details).not.toMatchObject({ status: "suppressed" });
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not cross accounts, delivery targets, or conflicting target fields", async () => {
|
|
const accountTool = createPollVoteTool();
|
|
await castBlueVote(accountTool);
|
|
await accountTool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
accountId: "secondary",
|
|
message: "Blue",
|
|
});
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(2);
|
|
|
|
const targetTool = createPollVoteTool();
|
|
await castBlueVote(targetTool, { chatGuid: "iMessage;-;+15559998888" });
|
|
await targetTool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "Blue",
|
|
});
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(4);
|
|
|
|
const conflictingTool = createPollVoteTool();
|
|
await castBlueVote(conflictingTool, {
|
|
target: currentChat,
|
|
chatGuid: "iMessage;-;+15559998888",
|
|
});
|
|
await conflictingTool.execute("send", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
target: currentChat,
|
|
message: "Blue",
|
|
});
|
|
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(6);
|
|
});
|
|
|
|
it("consumes the guard on the first same-route visible send", async () => {
|
|
const tool = createPollVoteTool();
|
|
await castBlueVote(tool);
|
|
await tool.execute("send-1", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "Blue, because it matches our theme",
|
|
});
|
|
await tool.execute("send-2", {
|
|
action: "send",
|
|
channel: "imessage",
|
|
message: "Blue",
|
|
});
|
|
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe("message tool secret scoping", () => {
|
|
it("marks message-tool-only source replies in the tool description", () => {
|
|
const scopedTool = createMessageTool({
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
const explicitTargetTool = createMessageTool({
|
|
requireExplicitTarget: true,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
const defaultTool = createMessageTool();
|
|
|
|
expect(scopedTool.description).toContain('visible reply: action="send" + message');
|
|
expect(scopedTool.description).toContain("target defaults current source");
|
|
expect(scopedTool.description).toContain("Final answer private");
|
|
expect(explicitTargetTool.description).toContain("send needs target");
|
|
expect(explicitTargetTool.description).not.toContain("target defaults current source");
|
|
expect(defaultTool.description).not.toContain('visible reply: action="send" + message');
|
|
});
|
|
|
|
it("forwards source reply delivery mode through createOpenClawTools", () => {
|
|
const tool = createOpenClawTools({
|
|
config: {} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
}).find((candidate) => candidate.name === "message");
|
|
|
|
expect(tool?.description).toContain('visible reply: action="send" + message');
|
|
});
|
|
|
|
it("passes source reply delivery mode to the outbound runner", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main",
|
|
},
|
|
});
|
|
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
|
});
|
|
|
|
it("defaults internal WebChat message tool sends to the source-reply sink", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:webchat:dm:dashboard",
|
|
},
|
|
});
|
|
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
|
expect(input?.params).toMatchObject({ action: "send", message: "hi" });
|
|
});
|
|
|
|
it("keeps automatic WebChat final-answer guidance while selecting the tool-local sink", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
currentChannelProvider: "webchat",
|
|
sourceReplyDeliveryMode: "automatic",
|
|
agentSessionKey: "agent:main:webchat:dm:dashboard",
|
|
},
|
|
});
|
|
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
|
const tool = createMessageTool({
|
|
currentChannelProvider: "webchat",
|
|
sourceReplyDeliveryMode: "automatic",
|
|
agentSessionKey: "agent:main:webchat:dm:dashboard",
|
|
});
|
|
expect(tool.description).not.toContain("Normal final answers stay private");
|
|
});
|
|
|
|
it("passes current inbound audio to the outbound runner", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
currentInboundAudio: true,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "telegram",
|
|
agentSessionKey: "agent:main:telegram:direct:123456789",
|
|
},
|
|
});
|
|
|
|
expect(input?.inboundAudio).toBe(true);
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
});
|
|
|
|
it("keeps direct operator authority on the in-process action only", async () => {
|
|
mockSendResult();
|
|
|
|
const direct = await executeSend({
|
|
action: { message: "direct" },
|
|
toolOptions: { conversationReadOrigin: "direct-operator" },
|
|
});
|
|
const delegated = await executeSend({
|
|
action: { message: "delegated" },
|
|
toolOptions: { conversationReadOrigin: "delegated" },
|
|
});
|
|
|
|
expect(direct?.conversationReadOrigin).toBe("direct-operator");
|
|
expect(direct?.gateway).toBeUndefined();
|
|
expect(delegated?.conversationReadOrigin).toBe("delegated");
|
|
expect(delegated?.gateway).toMatchObject({ timeoutMs: expect.any(Number) });
|
|
});
|
|
|
|
it("reads steered inbound audio when the message action runs", async () => {
|
|
mockSendResult();
|
|
let hasCurrentInboundAudio = false;
|
|
const tool = createMessageTool({
|
|
currentInboundAudio: false,
|
|
hasCurrentInboundAudio: () => hasCurrentInboundAudio,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "whatsapp",
|
|
agentSessionKey: "agent:main:whatsapp:direct:123456789",
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
hasCurrentInboundAudio = true;
|
|
|
|
await tool.execute("call1", { action: "send", message: "hi" });
|
|
|
|
expect(lastRunMessageActionInput()?.inboundAudio).toBe(true);
|
|
});
|
|
|
|
it("adds a current-run idempotency key when the model omits one", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: { runId: "run-message-tool" },
|
|
});
|
|
|
|
expect(input?.params?.idempotencyKey).toMatch(
|
|
/^run-message-tool:message-tool:[A-Za-z0-9_-]+:[A-Za-z0-9._:-]+$/,
|
|
);
|
|
});
|
|
|
|
it("keeps the Codex final control out of delivery and retry idempotency", async () => {
|
|
mocks.runMessageAction
|
|
.mockRejectedValueOnce(new Error("gateway timeout"))
|
|
.mockResolvedValueOnce({
|
|
kind: "send",
|
|
action: "send",
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
handledBy: "plugin",
|
|
payload: {},
|
|
dryRun: true,
|
|
} satisfies MessageActionRunResult);
|
|
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
runId: "run-message-tool",
|
|
});
|
|
|
|
await expect(
|
|
tool.execute("message_111_1", {
|
|
action: "send",
|
|
message: "same",
|
|
to: "123",
|
|
timeoutMs: 1,
|
|
final: true,
|
|
}),
|
|
).rejects.toThrow("gateway timeout");
|
|
const first = firstRunMessageActionInput();
|
|
|
|
await tool.execute("message_222_1", {
|
|
action: "send",
|
|
timeoutMs: 30_000,
|
|
to: "123",
|
|
message: "same",
|
|
final: false,
|
|
});
|
|
const second = lastRunMessageActionInput();
|
|
|
|
expect(first?.params?.idempotencyKey).toBe(second?.params?.idempotencyKey);
|
|
expect(first?.params).not.toHaveProperty("final");
|
|
expect(second?.params).not.toHaveProperty("final");
|
|
});
|
|
|
|
it("carries terminal source-reply intent outside provider params", async () => {
|
|
mockSendResult();
|
|
const sessionKey = "agent:main:telegram:direct:123";
|
|
const turnCapability = mintMessageActionTurnCapability({
|
|
agentId: "main",
|
|
runId: "run-source-reply",
|
|
sessionId: "session-source-reply",
|
|
sessionKey,
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "123",
|
|
currentSourceTurnId: "source-turn-1",
|
|
},
|
|
});
|
|
mintedTurnCapabilities.push(turnCapability);
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
agentId: "main",
|
|
agentSessionKey: sessionKey,
|
|
runId: "run-source-reply",
|
|
sessionId: "session-source-reply",
|
|
messageActionTurnCapability: turnCapability,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
|
|
await tool.execute("message_progress", {
|
|
action: "send",
|
|
message: "progress",
|
|
to: "123",
|
|
final: false,
|
|
});
|
|
await tool.execute("message_terminal", {
|
|
action: "send",
|
|
message: "done",
|
|
to: "123",
|
|
});
|
|
|
|
const [progress, terminal] = mocks.runMessageAction.mock.calls.map((call) => call[0]);
|
|
expect(progress?.sourceReplyFinal).toBe(false);
|
|
expect(terminal?.sourceReplyFinal).toBe(true);
|
|
expect(progress?.sourceReplyToolCallId).toBe("message_progress");
|
|
expect(terminal?.sourceReplyToolCallId).toBe("message_terminal");
|
|
expect(progress?.params).not.toHaveProperty("final");
|
|
expect(terminal?.params).not.toHaveProperty("final");
|
|
});
|
|
|
|
it("assigns remote terminal source-reply receipts to the caller", async () => {
|
|
mockSendResult();
|
|
mocks.getRuntimeConfig.mockReturnValue({
|
|
gateway: {
|
|
mode: "remote",
|
|
remote: { url: "wss://gateway.example" },
|
|
},
|
|
});
|
|
const sessionKey = "agent:main:telegram:direct:123";
|
|
const turnCapability = mintMessageActionTurnCapability({
|
|
agentId: "main",
|
|
runId: "run-remote-source-reply",
|
|
sessionId: "session-remote-source-reply",
|
|
sessionKey,
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "123",
|
|
currentSourceTurnId: "source-turn-remote",
|
|
},
|
|
});
|
|
mintedTurnCapabilities.push(turnCapability);
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
agentId: "main",
|
|
agentSessionKey: sessionKey,
|
|
runId: "run-remote-source-reply",
|
|
sessionId: "session-remote-source-reply",
|
|
messageActionTurnCapability: turnCapability,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
|
|
await tool.execute("message_terminal_remote", {
|
|
action: "send",
|
|
message: "done",
|
|
to: "123",
|
|
});
|
|
|
|
const terminal = firstRunMessageActionInput();
|
|
expect(terminal?.sourceReplyFinal).toBe(true);
|
|
expect(terminal?.gateway?.terminalSourceReplyReceiptOwner).toBe("caller");
|
|
expect(terminal?.gateway?.resolveAgentRuntimeIdentityToken).toEqual(expect.any(Function));
|
|
});
|
|
|
|
it("keeps source-less message-tool-only sends outside terminal reconciliation", async () => {
|
|
mockSendResult();
|
|
const sessionKey = "agent:main:telegram:direct:scheduled";
|
|
const sourceLessCapability = mintMessageActionTurnCapability({
|
|
agentId: "main",
|
|
runId: "run-source-less",
|
|
sessionId: "session-source-less",
|
|
sessionKey,
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "scheduled",
|
|
},
|
|
});
|
|
mintedTurnCapabilities.push(sourceLessCapability);
|
|
const createSourceLessTool = (messageActionTurnCapability?: string) =>
|
|
createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
agentId: "main",
|
|
agentSessionKey: sessionKey,
|
|
runId: "run-source-less",
|
|
sessionId: "session-source-less",
|
|
messageActionTurnCapability,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
|
|
await createSourceLessTool().execute("message-scheduled", {
|
|
action: "send",
|
|
message: "scheduled update",
|
|
to: "scheduled",
|
|
});
|
|
await createSourceLessTool(sourceLessCapability).execute("message-room-event", {
|
|
action: "send",
|
|
message: "ambient update",
|
|
to: "scheduled",
|
|
});
|
|
|
|
for (const [input] of mocks.runMessageAction.mock.calls) {
|
|
expect(input.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input.sourceReplyFinal).toBeUndefined();
|
|
expect(input.sourceReplyToolCallId).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("rejects a supplied turn capability after revocation", async () => {
|
|
const sessionKey = "agent:main:telegram:direct:revoked";
|
|
const revokedCapability = mintMessageActionTurnCapability({
|
|
agentId: "main",
|
|
runId: "run-revoked",
|
|
sessionId: "session-revoked",
|
|
sessionKey,
|
|
toolContext: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "revoked",
|
|
currentSourceTurnId: "channel-user:v1:revoked",
|
|
},
|
|
});
|
|
revokeMessageActionTurnCapability(revokedCapability);
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
agentId: "main",
|
|
agentSessionKey: sessionKey,
|
|
runId: "run-revoked",
|
|
sessionId: "session-revoked",
|
|
messageActionTurnCapability: revokedCapability,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
});
|
|
|
|
await expect(
|
|
tool.execute("message-revoked", {
|
|
action: "send",
|
|
message: "must not send",
|
|
to: "revoked",
|
|
}),
|
|
).rejects.toThrow("message action turn capability is no longer active");
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses delivery params to avoid collisions across distinct sends", async () => {
|
|
mockSendResult();
|
|
|
|
const first = await executeSend({
|
|
action: { message: "first" },
|
|
toolOptions: { runId: "run-message-tool" },
|
|
toolCallId: "message_111_1",
|
|
});
|
|
const second = await executeSend({
|
|
action: { message: "second" },
|
|
toolOptions: { runId: "run-message-tool" },
|
|
toolCallId: "message_222_1",
|
|
});
|
|
|
|
expect(first?.params?.idempotencyKey).not.toBe(second?.params?.idempotencyKey);
|
|
});
|
|
|
|
it("uses a fresh autogenerated idempotency key after a successful identical send", async () => {
|
|
mockSendResult();
|
|
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
runId: "run-message-tool",
|
|
});
|
|
|
|
await tool.execute("message_111_1", {
|
|
action: "send",
|
|
message: "same",
|
|
to: "123",
|
|
});
|
|
const first = firstRunMessageActionInput();
|
|
await tool.execute("message_222_1", {
|
|
action: "send",
|
|
to: "123",
|
|
message: "same",
|
|
});
|
|
const second = lastRunMessageActionInput();
|
|
|
|
expect(first?.params?.idempotencyKey).not.toBe(second?.params?.idempotencyKey);
|
|
});
|
|
|
|
it("uses separate autogenerated idempotency keys for parallel identical sends", async () => {
|
|
const pending: Array<(value: MessageActionRunResult) => void> = [];
|
|
mocks.runMessageAction.mockImplementation(
|
|
() =>
|
|
new Promise<MessageActionRunResult>((resolve) => {
|
|
pending.push(resolve);
|
|
}),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
runId: "run-message-tool",
|
|
});
|
|
|
|
const firstResult = tool.execute("message_111_1", {
|
|
action: "send",
|
|
message: "same",
|
|
to: "123",
|
|
});
|
|
const secondResult = tool.execute("message_222_1", {
|
|
action: "send",
|
|
to: "123",
|
|
message: "same",
|
|
});
|
|
|
|
for (let i = 0; i < 10 && mocks.runMessageAction.mock.calls.length < 2; i += 1) {
|
|
await Promise.resolve();
|
|
}
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(2);
|
|
const first = mocks.runMessageAction.mock.calls[0]?.[0] as RunMessageActionInput | undefined;
|
|
const second = mocks.runMessageAction.mock.calls[1]?.[0] as RunMessageActionInput | undefined;
|
|
expect(first?.params?.idempotencyKey).not.toBe(second?.params?.idempotencyKey);
|
|
|
|
for (const resolve of pending) {
|
|
resolve({
|
|
kind: "send",
|
|
action: "send",
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
handledBy: "plugin",
|
|
payload: {},
|
|
dryRun: true,
|
|
});
|
|
}
|
|
await Promise.all([firstResult, secondResult]);
|
|
});
|
|
|
|
it("keeps nested delivery fields in autogenerated idempotency keys", async () => {
|
|
mockSendResult();
|
|
|
|
const first = await executeSend({
|
|
action: {
|
|
message: "pay",
|
|
channelData: { button: { idempotencyKey: "invoice-A" } },
|
|
},
|
|
toolOptions: { runId: "run-message-tool" },
|
|
});
|
|
const second = await executeSend({
|
|
action: {
|
|
message: "pay",
|
|
channelData: { button: { idempotencyKey: "invoice-B" } },
|
|
},
|
|
toolOptions: { runId: "run-message-tool" },
|
|
});
|
|
|
|
expect(first?.params?.idempotencyKey).not.toBe(second?.params?.idempotencyKey);
|
|
});
|
|
|
|
it("uses a non-webchat session key when ambient current channel drifted to webchat", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:telegram:group:-5150615830",
|
|
},
|
|
});
|
|
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("telegram");
|
|
expect(input?.toolContext?.currentChannelId).toBe("-5150615830");
|
|
expect(input?.toolContext?.currentChatType).toBe("group");
|
|
expect(input?.params).toEqual({ action: "send", message: "hi" });
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.telegram.botToken"]);
|
|
});
|
|
|
|
it("preserves a routable current target that differs from the channel id", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
currentChannelProvider: "msteams",
|
|
currentChannelId: "conversation:19:channel@thread.tacv2",
|
|
currentChatType: "channel",
|
|
currentMessagingTarget: "graph-team/19:channel@thread.tacv2",
|
|
},
|
|
});
|
|
|
|
expect(input?.toolContext).toMatchObject({
|
|
currentChannelProvider: "msteams",
|
|
currentChannelId: "conversation:19:channel@thread.tacv2",
|
|
currentChatType: "channel",
|
|
currentMessagingTarget: "graph-team/19:channel@thread.tacv2",
|
|
});
|
|
});
|
|
|
|
it("preserves empty opaque target segments in inferred session delivery", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:telegram:group:room::part",
|
|
},
|
|
});
|
|
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("telegram");
|
|
expect(input?.toolContext?.currentChannelId).toBe("room::part");
|
|
});
|
|
|
|
it("does not infer delivery from empty structural session segments", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:telegram::group:room",
|
|
},
|
|
});
|
|
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
|
expect(input?.toolContext?.currentChannelId).toBeUndefined();
|
|
});
|
|
|
|
it("does not infer delivery from a nested opaque agent identity", async () => {
|
|
mockSendResult();
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:voice:agent:channel:room",
|
|
},
|
|
});
|
|
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("webchat");
|
|
expect(input?.toolContext?.currentChannelId).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "declared user-prefixed",
|
|
channel: "discord",
|
|
declareUserPrefix: true,
|
|
sessionKey: "agent:main:discord:direct:123456789",
|
|
expectedTarget: "user:123456789",
|
|
secretField: "token",
|
|
secretId: "DISCORD_TOKEN",
|
|
},
|
|
{
|
|
name: "undeclared provider-native",
|
|
channel: "telegram",
|
|
declareUserPrefix: false,
|
|
sessionKey: "agent:main:telegram:direct:123456789",
|
|
expectedTarget: "123456789",
|
|
secretField: "botToken",
|
|
secretId: "TELEGRAM_BOT_TOKEN",
|
|
},
|
|
])(
|
|
"uses $name DM target metadata when ambient channel drifted to webchat",
|
|
async ({ channel, declareUserPrefix, sessionKey, expectedTarget, secretField, secretId }) => {
|
|
registerMessagingPlugin(
|
|
channel,
|
|
declareUserPrefix ? { directTargetStyle: "user-prefixed" } : {},
|
|
);
|
|
mockSendResult({ channel, to: expectedTarget });
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
[channel]: {
|
|
[secretField]: { source: "env", provider: "default", id: secretId },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: sessionKey,
|
|
},
|
|
});
|
|
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe(channel);
|
|
expect(input?.toolContext?.currentChannelId).toBe(expectedTarget);
|
|
expect(input?.params).toEqual({ action: "send", message: "hi" });
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual([
|
|
`channels.${channel}.${secretField}`,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("preserves MS Teams DM session keys as explicit user targets when ambient channel drifted to webchat", async () => {
|
|
registerMessagingPlugin("msteams", { directTargetStyle: "user-prefixed" });
|
|
mockSendResult({ channel: "msteams", to: "user:user-1" });
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
msteams: {
|
|
appPassword: { source: "env", provider: "default", id: "MSTEAMS_APP_PASSWORD" },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:msteams:dm:user-1",
|
|
},
|
|
});
|
|
|
|
expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("msteams");
|
|
expect(input?.toolContext?.currentChannelId).toBe("user:user-1");
|
|
expect(input?.toolContext?.currentChatType).toBe("direct");
|
|
expect(input?.params).toEqual({ action: "send", message: "hi" });
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.msteams.appPassword"]);
|
|
});
|
|
|
|
it("uses account-scoped session keys for secret and account fallback when ambient channel drifted to webchat", async () => {
|
|
registerMessagingPlugin("discord", { directTargetStyle: "user-prefixed" });
|
|
mockSendResult({ channel: "discord", to: "user:123456789" });
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
discord: {
|
|
token: { source: "env", provider: "default", id: "DISCORD_TOKEN" },
|
|
accounts: {
|
|
ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } },
|
|
},
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:discord:ops:direct:123456789",
|
|
},
|
|
});
|
|
|
|
expect(input?.defaultAccountId).toBe("ops");
|
|
expect(input?.params?.accountId).toBe("ops");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("discord");
|
|
expect(input?.toolContext?.currentChannelId).toBe("user:123456789");
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual([
|
|
"channels.discord.token",
|
|
"channels.discord.accounts.ops.token",
|
|
]);
|
|
});
|
|
|
|
it("keeps account-scoped direct keys when account id matches a peer marker", async () => {
|
|
registerMessagingPlugin("discord", { directTargetStyle: "user-prefixed" });
|
|
mockSendResult({ channel: "discord", to: "user:123456789" });
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
discord: {
|
|
token: { source: "env", provider: "default", id: "DISCORD_TOKEN" },
|
|
accounts: {
|
|
direct: {
|
|
token: { source: "env", provider: "default", id: "DISCORD_DIRECT_TOKEN" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:discord:direct:direct:123456789",
|
|
},
|
|
});
|
|
|
|
expect(input?.defaultAccountId).toBe("direct");
|
|
expect(input?.params?.accountId).toBe("direct");
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("discord");
|
|
expect(input?.toolContext?.currentChannelId).toBe("user:123456789");
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual([
|
|
"channels.discord.token",
|
|
"channels.discord.accounts.direct.token",
|
|
]);
|
|
});
|
|
|
|
it("handles legacy dm markers when ambient channel drifted to webchat", async () => {
|
|
registerMessagingPlugin("slack", { directTargetStyle: "user-prefixed" });
|
|
mockSendResult({ channel: "slack", to: "user:u123" });
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
slack: {
|
|
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:slack:dm:u123:thread:171.222",
|
|
},
|
|
});
|
|
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("slack");
|
|
expect(input?.toolContext?.currentChannelId).toBe("user:u123");
|
|
expect(input?.toolContext?.currentThreadTs).toBe("171.222");
|
|
expect(input?.toolContext?.replyToMode).toBe("all");
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.slack.botToken"]);
|
|
});
|
|
|
|
it("carries session-key thread suffixes into inferred channel context", async () => {
|
|
mockSendResult({ channel: "slack", to: "channel:c1" });
|
|
|
|
const input = await executeSend({
|
|
action: { message: "hi" },
|
|
toolOptions: {
|
|
config: {
|
|
channels: {
|
|
slack: {
|
|
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
|
},
|
|
},
|
|
} as never,
|
|
sourceReplyDeliveryMode: "message_tool_only",
|
|
currentChannelProvider: "webchat",
|
|
agentSessionKey: "agent:main:slack:channel:c1:thread:1710000000.9999",
|
|
},
|
|
});
|
|
|
|
expect(input?.toolContext?.currentChannelProvider).toBe("slack");
|
|
expect(input?.toolContext?.currentChannelId).toBe("c1");
|
|
expect(input?.toolContext?.currentThreadTs).toBe("1710000000.9999");
|
|
expect(input?.toolContext?.replyToMode).toBe("all");
|
|
});
|
|
|
|
it("scopes command-time secret resolution to the selected channel/account", async () => {
|
|
mockSendResult({ channel: "discord", to: "discord:123" });
|
|
mocks.getRuntimeConfig.mockReturnValue({
|
|
channels: {
|
|
discord: {
|
|
token: { source: "env", provider: "default", id: "DISCORD_TOKEN" },
|
|
accounts: {
|
|
ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } },
|
|
chat: { token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" } },
|
|
},
|
|
},
|
|
slack: {
|
|
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const tool = createMessageTool({
|
|
currentChannelProvider: "discord",
|
|
agentAccountId: "ops",
|
|
getRuntimeConfig: mocks.getRuntimeConfig as never,
|
|
getScopedChannelsCommandSecretTargets: mocks.getScopedChannelsCommandSecretTargets as never,
|
|
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
target: "channel:123",
|
|
message: "hi",
|
|
});
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(secretResolveCall.targetIds).toBeInstanceOf(Set);
|
|
expect(
|
|
[...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.discord.")),
|
|
).toBe(true);
|
|
expect(secretResolveCall.allowedPaths).toEqual(
|
|
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
|
|
);
|
|
});
|
|
|
|
it("resolves scoped channel SecretRefs even when constructed with a config snapshot", async () => {
|
|
mockSendResult({ channel: "discord", to: "channel:123" });
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "test",
|
|
actions: ["send"],
|
|
});
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
const rawConfig = {
|
|
channels: {
|
|
discord: {
|
|
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
|
|
accounts: {
|
|
ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const resolvedConfig = {
|
|
channels: {
|
|
discord: {
|
|
token: "resolved-discord-token",
|
|
accounts: {
|
|
ops: { token: "resolved-discord-ops-token" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
|
resolvedConfig,
|
|
diagnostics: [],
|
|
});
|
|
|
|
const tool = createMessageTool({
|
|
config: rawConfig as never,
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "channel:123",
|
|
agentAccountId: "ops",
|
|
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
message: "hi",
|
|
});
|
|
|
|
const secretResolveCall = latestSecretResolveCall();
|
|
expect(secretResolveCall.config).toBe(rawConfig);
|
|
expect(secretResolveCall.targetIds).toEqual(
|
|
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
|
|
);
|
|
expect(secretResolveCall.allowedPaths).toEqual(
|
|
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
|
|
);
|
|
expect(firstRunMessageActionInput()?.cfg).toBe(resolvedConfig);
|
|
});
|
|
});
|
|
|
|
describe("message tool delivery mode schema", () => {
|
|
it("hides bestEffort when required durable delivery is not available", () => {
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "test",
|
|
actions: ["send"],
|
|
});
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
|
|
const defaultTool = createMessageTool();
|
|
const scopedTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "discord",
|
|
});
|
|
|
|
expect(getToolProperties(defaultTool).bestEffort).toBeUndefined();
|
|
expect(getToolProperties(scopedTool).bestEffort).toBeUndefined();
|
|
});
|
|
|
|
it("exposes bestEffort only for channels that can reconcile unknown sends", () => {
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "test",
|
|
actions: ["send"],
|
|
message: {
|
|
durableFinal: {
|
|
capabilities: { reconcileUnknownSend: true },
|
|
reconcileUnknownSend: async () => ({ status: "not_sent" }),
|
|
},
|
|
},
|
|
});
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "discord",
|
|
});
|
|
const bestEffort = getToolProperties(tool).bestEffort as
|
|
| { description?: string; type?: string }
|
|
| undefined;
|
|
|
|
expect(bestEffort?.type).toBe("boolean");
|
|
expect(bestEffort?.description).toContain("requiring durable delivery");
|
|
});
|
|
});
|
|
|
|
describe("message tool agent routing", () => {
|
|
it("derives agentId from the session key", async () => {
|
|
mockSendResult();
|
|
|
|
const tool = createMessageTool({
|
|
agentSessionKey: "agent:alpha:main",
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
target: "telegram:123",
|
|
message: "hi",
|
|
});
|
|
|
|
const call = firstRunMessageActionInput();
|
|
expect(call?.agentId).toBe("alpha");
|
|
expect(call?.sessionKey).toBe("agent:alpha:main");
|
|
});
|
|
|
|
it("uses agentThreadId as ambient thread context when currentThreadTs is absent", async () => {
|
|
mockSendResult({ channel: "slack", to: "channel:C123" });
|
|
|
|
const tool = createMessageTool({
|
|
agentSessionKey: "agent:main:slack:channel:c123:thread:111.222",
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
currentChannelProvider: "slack",
|
|
currentChannelId: "channel:C123",
|
|
agentThreadId: "111.222",
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
channel: "slack",
|
|
message: "stay in thread",
|
|
});
|
|
|
|
const call = firstRunMessageActionInput();
|
|
expect(call?.toolContext?.currentThreadTs).toBe("111.222");
|
|
expect(call?.toolContext?.replyToMode).toBe("all");
|
|
});
|
|
|
|
it("keeps explicit reply mode opt-out when agentThreadId is present", async () => {
|
|
mockSendResult({ channel: "slack", to: "channel:C123" });
|
|
|
|
const tool = createMessageTool({
|
|
agentSessionKey: "agent:main:slack:channel:c123:thread:111.222",
|
|
getRuntimeConfig: mocks.getRuntimeConfig,
|
|
currentChannelProvider: "slack",
|
|
currentChannelId: "channel:C123",
|
|
agentThreadId: "111.222",
|
|
replyToMode: "off",
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
channel: "slack",
|
|
message: "send at channel level",
|
|
});
|
|
|
|
const call = firstRunMessageActionInput();
|
|
expect(call?.toolContext?.currentThreadTs).toBe("111.222");
|
|
expect(call?.toolContext?.replyToMode).toBe("off");
|
|
});
|
|
|
|
it("forwards agentThreadId through createOpenClawTools to the message tool", async () => {
|
|
mockSendResult({ channel: "slack", to: "channel:C123" });
|
|
const plugin = createChannelPlugin({
|
|
id: "slack",
|
|
label: "Slack",
|
|
docsPath: "/channels/slack",
|
|
blurb: "test",
|
|
actions: ["send"],
|
|
});
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "slack", source: "test", plugin }]));
|
|
|
|
const tool = createOpenClawTools({
|
|
agentSessionKey: "agent:main:slack:channel:c123:thread:111.222",
|
|
config: {} as never,
|
|
agentChannel: "slack",
|
|
currentChannelId: "channel:C123",
|
|
agentThreadId: "111.222",
|
|
}).find((candidate) => candidate.name === "message");
|
|
|
|
if (!tool) {
|
|
throw new Error("message tool not found");
|
|
}
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
channel: "slack",
|
|
message: "stay in thread",
|
|
});
|
|
|
|
const call = firstRunMessageActionInput();
|
|
expect(call?.toolContext?.currentThreadTs).toBe("111.222");
|
|
expect(call?.toolContext?.replyToMode).toBe("all");
|
|
});
|
|
|
|
it("forwards the routable target through createOpenClawTools to the message tool", async () => {
|
|
mockSendResult({ channel: "slack", to: "user:U123" });
|
|
const plugin = createChannelPlugin({
|
|
id: "slack",
|
|
label: "Slack",
|
|
docsPath: "/channels/slack",
|
|
blurb: "test",
|
|
actions: ["send"],
|
|
});
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "slack", source: "test", plugin }]));
|
|
|
|
const tool = createOpenClawTools({
|
|
config: {} as never,
|
|
agentChannel: "slack",
|
|
currentChannelId: "D123",
|
|
currentChatType: "direct",
|
|
currentMessagingTarget: "user:U123",
|
|
currentThreadTs: "111.222",
|
|
replyToMode: "all",
|
|
}).find((candidate) => candidate.name === "message");
|
|
|
|
if (!tool) {
|
|
throw new Error("message tool not found");
|
|
}
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
channel: "slack",
|
|
target: "user:U123",
|
|
message: "stay in DM thread",
|
|
});
|
|
|
|
const call = firstRunMessageActionInput();
|
|
expect(call?.toolContext).toMatchObject({
|
|
currentChannelId: "D123",
|
|
currentChatType: "direct",
|
|
currentMessagingTarget: "user:U123",
|
|
currentChannelProvider: "slack",
|
|
currentThreadTs: "111.222",
|
|
replyToMode: "all",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("message tool explicit target guard", () => {
|
|
it("requires an explicit target for upload-file when configured", async () => {
|
|
const tool = createMessageTool({
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
requireExplicitTarget: true,
|
|
currentChannelProvider: "slack",
|
|
currentChannelId: "channel:C123",
|
|
});
|
|
|
|
await expect(
|
|
tool.execute("1", {
|
|
action: "upload-file",
|
|
filePath: "/tmp/report.png",
|
|
}),
|
|
).rejects.toThrow(/Explicit message target required/i);
|
|
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
action: "poll",
|
|
params: {
|
|
action: "poll",
|
|
pollQuestion: "Lunch?",
|
|
pollOption: ["Pizza", "Sushi"],
|
|
},
|
|
},
|
|
{
|
|
action: "sticker",
|
|
params: {
|
|
action: "sticker",
|
|
stickerId: "sticker-1",
|
|
},
|
|
},
|
|
] as const)("requires an explicit target for $action when configured", async ({ params }) => {
|
|
const tool = createMessageTool({
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
requireExplicitTarget: true,
|
|
currentChannelProvider: "slack",
|
|
currentChannelId: "channel:C123",
|
|
});
|
|
|
|
await expect(tool.execute("1", params)).rejects.toThrow(/Explicit message target required/i);
|
|
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows upload-file when an explicit target is provided", async () => {
|
|
mocks.runMessageAction.mockResolvedValueOnce({
|
|
kind: "action",
|
|
channel: "slack",
|
|
action: "upload-file",
|
|
handledBy: "dry-run",
|
|
payload: { ok: true, dryRun: true, channel: "slack", action: "upload-file" },
|
|
dryRun: true,
|
|
});
|
|
|
|
const tool = createMessageTool({
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
requireExplicitTarget: true,
|
|
currentChannelProvider: "slack",
|
|
currentChannelId: "channel:C123",
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "upload-file",
|
|
target: "channel:C999",
|
|
filePath: "/tmp/report.png",
|
|
});
|
|
|
|
const call = firstRunMessageActionInput();
|
|
expect(call?.params?.target).toBe("channel:C999");
|
|
});
|
|
});
|
|
|
|
describe("message tool loop detection action runner proof", () => {
|
|
function mockQaChannelGatewayActionRunner() {
|
|
mocks.runMessageAction.mockImplementation(async ({ params }) => {
|
|
const callIndex = mocks.runMessageAction.mock.calls.length;
|
|
return {
|
|
kind: "send",
|
|
action: "send",
|
|
channel: "qa-channel",
|
|
to: typeof params?.target === "string" ? params.target : "channel:loop-room",
|
|
handledBy: "plugin",
|
|
payload: {
|
|
message: {
|
|
id: `qa-message-${callIndex}`,
|
|
accountId: "default",
|
|
direction: "outbound",
|
|
conversation: {
|
|
id: "loop-room",
|
|
chatType: "channel",
|
|
},
|
|
senderId: "openclaw",
|
|
text: "same visible reply",
|
|
timestamp: 1_800_000_000_000 + callIndex,
|
|
},
|
|
},
|
|
dryRun: false,
|
|
} satisfies MessageActionRunResult;
|
|
});
|
|
}
|
|
|
|
it("blocks repeated qa-channel sends returned by the wrapped message tool", async () => {
|
|
mockQaChannelGatewayActionRunner();
|
|
const messageTool = createMessageTool({
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
const wrappedTool = wrapToolWithBeforeToolCallHook(messageTool, {
|
|
agentId: "main",
|
|
sessionKey: "message-tool-action-runner-loop",
|
|
sessionId: "message-tool-action-runner-loop-session",
|
|
runId: "message-tool-action-runner-loop-run",
|
|
loopDetection: { enabled: true },
|
|
});
|
|
const params = {
|
|
action: "send",
|
|
target: "channel:loop-room",
|
|
message: "same visible reply",
|
|
};
|
|
|
|
for (let i = 0; i < CRITICAL_THRESHOLD; i += 1) {
|
|
const result = await wrappedTool.execute(`message-tool-send-${i}`, params);
|
|
expect(result.details).toMatchObject({
|
|
message: {
|
|
conversation: {
|
|
id: "loop-room",
|
|
},
|
|
text: "same visible reply",
|
|
},
|
|
});
|
|
}
|
|
|
|
const blocked = await wrappedTool.execute(`message-tool-send-${CRITICAL_THRESHOLD}`, params);
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(CRITICAL_THRESHOLD);
|
|
expect(blocked.details).toMatchObject({
|
|
status: "blocked",
|
|
deniedReason: "tool-loop",
|
|
});
|
|
const blockedDetails = blocked.details as { reason?: unknown } | undefined;
|
|
expect(String(blockedDetails?.reason)).toContain("CRITICAL");
|
|
|
|
const blockedAgain = await wrappedTool.execute(
|
|
`message-tool-send-${CRITICAL_THRESHOLD + 1}`,
|
|
params,
|
|
);
|
|
expect(mocks.runMessageAction).toHaveBeenCalledTimes(CRITICAL_THRESHOLD);
|
|
expect(blockedAgain.details).toMatchObject({
|
|
status: "blocked",
|
|
deniedReason: "tool-loop",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("message tool path passthrough", () => {
|
|
it("advertises canonical media params without compat aliases", () => {
|
|
const properties = getToolProperties(createMessageTool());
|
|
const attachments = properties.attachments as
|
|
| { items?: { properties?: Record<string, unknown> } }
|
|
| undefined;
|
|
const attachmentProperties = attachments?.items?.properties ?? {};
|
|
|
|
expect(properties).toHaveProperty("media");
|
|
expect(properties).not.toHaveProperty("mediaUrl");
|
|
expect(properties).not.toHaveProperty("mediaUrls");
|
|
expect(properties).not.toHaveProperty("path");
|
|
expect(properties).not.toHaveProperty("filePath");
|
|
expect(properties).not.toHaveProperty("fileUrl");
|
|
expect(attachmentProperties).toHaveProperty("media");
|
|
for (const name of ["mediaUrl", "path", "filePath", "fileUrl", "url"]) {
|
|
expect(attachmentProperties).not.toHaveProperty(name);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ field: "path", value: "~/Downloads/voice.ogg" },
|
|
{ field: "filePath", value: "./tmp/note.m4a" },
|
|
])("does not convert $field to media for send", async ({ field, value }) => {
|
|
mockSendResult({ to: "telegram:123" });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
[field]: value,
|
|
message: "",
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.[field]).toBe(value);
|
|
expect(call?.params?.media).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("message tool Telegram topic targets", () => {
|
|
it("passes numeric forum topic targets and thread ids to outbound resolution", async () => {
|
|
mockSendResult({ to: "telegram:-1001234567890:topic:42" });
|
|
|
|
const call = await executeSend({
|
|
toolOptions: {
|
|
currentChannelProvider: "telegram",
|
|
currentChannelId: "telegram:-1001234567890:topic:42",
|
|
},
|
|
action: {
|
|
channel: "telegram",
|
|
target: "-1001234567890:topic:42",
|
|
threadId: "42",
|
|
message: "topic hello",
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.channel).toBe("telegram");
|
|
expect(call?.params?.target).toBe("-1001234567890:topic:42");
|
|
expect(call?.params?.threadId).toBe("42");
|
|
expect(call?.params?.message).toBe("topic hello");
|
|
});
|
|
});
|
|
|
|
describe("message tool schema scoping", () => {
|
|
const telegramPlugin = createChannelPlugin({
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram test plugin.",
|
|
actions: ["send", "react", "poll"],
|
|
capabilities: ["presentation"],
|
|
toolSchema: () => [
|
|
{
|
|
properties: createTelegramPollExtraToolSchemas(),
|
|
visibility: "all-configured",
|
|
},
|
|
],
|
|
});
|
|
|
|
const discordPlugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord test plugin.",
|
|
actions: ["send", "poll", "poll-vote"],
|
|
capabilities: ["presentation"],
|
|
});
|
|
|
|
const slackPlugin = createChannelPlugin({
|
|
id: "slack",
|
|
label: "Slack",
|
|
docsPath: "/channels/slack",
|
|
blurb: "Slack test plugin.",
|
|
actions: ["send", "react"],
|
|
capabilities: ["presentation"],
|
|
});
|
|
|
|
afterEach(() => {
|
|
setActivePluginRegistry(createTestRegistry([]));
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
provider: "telegram",
|
|
expectTelegramPollExtras: true,
|
|
expectedActions: ["send", "react", "poll", "poll-vote"],
|
|
},
|
|
{
|
|
provider: "discord",
|
|
expectTelegramPollExtras: true,
|
|
expectedActions: ["send", "poll", "poll-vote", "react"],
|
|
},
|
|
{
|
|
provider: "slack",
|
|
expectTelegramPollExtras: true,
|
|
expectedActions: ["send", "react", "poll", "poll-vote"],
|
|
},
|
|
])(
|
|
"scopes schema fields for $provider",
|
|
({ provider, expectTelegramPollExtras, expectedActions }) => {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
|
|
{ pluginId: "discord", source: "test", plugin: discordPlugin },
|
|
{ pluginId: "slack", source: "test", plugin: slackPlugin },
|
|
]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: provider,
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
const actionEnum = getActionEnum(properties);
|
|
const presentationSchemaJson = JSON.stringify(properties.presentation);
|
|
const presentationBlockItemSchema = (
|
|
properties.presentation as {
|
|
properties?: { blocks?: { items?: Record<string, unknown> } };
|
|
}
|
|
).properties?.blocks?.items;
|
|
const presentationActionVariants = (
|
|
presentationBlockItemSchema as {
|
|
properties?: {
|
|
buttons?: {
|
|
items?: { properties?: { action?: { anyOf?: Array<Record<string, unknown>> } } };
|
|
};
|
|
};
|
|
}
|
|
).properties?.buttons?.items?.properties?.action?.anyOf;
|
|
const webAppRequiredFields = presentationActionVariants
|
|
?.filter(
|
|
(variant) =>
|
|
(variant.properties as { type?: { const?: string } } | undefined)?.type?.const ===
|
|
"web-app",
|
|
)
|
|
.map((variant) => variant.required);
|
|
|
|
expect(properties).toHaveProperty("presentation");
|
|
expect(presentationSchemaJson).toContain('"action"');
|
|
expect(presentationSchemaJson).toContain('"command"');
|
|
expect(presentationSchemaJson).toContain('"const":"url"');
|
|
expect(presentationSchemaJson).toContain('"const":"web-app"');
|
|
expect(presentationSchemaJson).toContain('"widgetId"');
|
|
expect(webAppRequiredFields).toEqual(
|
|
expect.arrayContaining([
|
|
["type", "url"],
|
|
["type", "widgetId"],
|
|
]),
|
|
);
|
|
expect(presentationSchemaJson).not.toContain('"const":"approval"');
|
|
expect(presentationSchemaJson).not.toContain('"const":"question"');
|
|
expect(presentationSchemaJson).toContain('"chartType"');
|
|
expect(presentationSchemaJson).toContain('"pie"');
|
|
expect(presentationSchemaJson).toContain('"table"');
|
|
expect(presentationSchemaJson).toContain('"caption"');
|
|
expect(presentationSchemaJson).toContain('"headers"');
|
|
expect(presentationSchemaJson).toContain('"rows"');
|
|
expect(presentationSchemaJson).toContain('"rowHeaderColumnIndex"');
|
|
expect(presentationSchemaJson).not.toContain('"maxItems"');
|
|
expect(presentationSchemaJson).not.toContain('"maxLength"');
|
|
expect(presentationSchemaJson).not.toContain('"exclusiveMinimum"');
|
|
expect(presentationBlockItemSchema).toMatchObject({ type: "object" });
|
|
expect(presentationBlockItemSchema).not.toHaveProperty("anyOf");
|
|
expect(
|
|
(
|
|
presentationBlockItemSchema as {
|
|
properties?: { rows?: { items?: { items?: unknown } } };
|
|
}
|
|
).properties?.rows?.items?.items,
|
|
).toEqual({ type: ["string", "number"] });
|
|
expect(properties.components).toBeUndefined();
|
|
expect(properties.blocks).toBeUndefined();
|
|
expect(properties.buttons).toBeUndefined();
|
|
for (const action of expectedActions) {
|
|
expect(actionEnum).toContain(action);
|
|
}
|
|
if (expectTelegramPollExtras) {
|
|
expect(properties).toHaveProperty("pollDurationSeconds");
|
|
expect(properties).toHaveProperty("pollAnonymous");
|
|
expect(properties).toHaveProperty("pollPublic");
|
|
} else {
|
|
expect(properties.pollDurationSeconds).toBeUndefined();
|
|
expect(properties.pollAnonymous).toBeUndefined();
|
|
expect(properties.pollPublic).toBeUndefined();
|
|
}
|
|
expect(properties).toHaveProperty("pollId");
|
|
expect(properties).toHaveProperty("pollOptionIndex");
|
|
expect(properties).toHaveProperty("pollOptionId");
|
|
},
|
|
);
|
|
|
|
it("includes poll in the action enum when the current channel supports poll actions", () => {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: telegramPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "telegram",
|
|
});
|
|
const actionEnum = getActionEnum(getToolProperties(tool));
|
|
|
|
expect(actionEnum).toContain("poll");
|
|
});
|
|
|
|
it("hides telegram poll extras when telegram polls are disabled in scoped mode", () => {
|
|
const telegramPluginWithConfig = createChannelPlugin({
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram test plugin.",
|
|
describeMessageTool: ({ cfg }) => {
|
|
const telegramCfg = (cfg as { channels?: { telegram?: { actions?: { poll?: boolean } } } })
|
|
.channels?.telegram;
|
|
return {
|
|
actions:
|
|
telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"],
|
|
capabilities: ["presentation"],
|
|
schema:
|
|
telegramCfg?.actions?.poll === false
|
|
? []
|
|
: [
|
|
{
|
|
properties: createTelegramPollExtraToolSchemas(),
|
|
visibility: "all-configured" as const,
|
|
},
|
|
],
|
|
};
|
|
},
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "telegram", source: "test", plugin: telegramPluginWithConfig },
|
|
]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
actions: {
|
|
poll: false,
|
|
},
|
|
},
|
|
},
|
|
} as never,
|
|
currentChannelProvider: "telegram",
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
const actionEnum = getActionEnum(properties);
|
|
|
|
expect(actionEnum).not.toContain("poll");
|
|
expect(properties.pollDurationSeconds).toBeUndefined();
|
|
expect(properties.pollAnonymous).toBeUndefined();
|
|
expect(properties.pollPublic).toBeUndefined();
|
|
});
|
|
|
|
it("uses discovery account scope for capability-gated presentation", () => {
|
|
const scopedInteractivePlugin = createChannelPlugin({
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram test plugin.",
|
|
describeMessageTool: ({ accountId }) => ({
|
|
actions: ["send"],
|
|
capabilities: accountId === "ops" ? ["presentation"] : [],
|
|
}),
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "telegram", source: "test", plugin: scopedInteractivePlugin },
|
|
]),
|
|
);
|
|
|
|
const scopedTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "telegram",
|
|
agentAccountId: "ops",
|
|
});
|
|
const unscopedTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "telegram",
|
|
});
|
|
|
|
expect(getToolProperties(scopedTool)).toHaveProperty("presentation");
|
|
expect(getToolProperties(unscopedTool).presentation).toBeUndefined();
|
|
});
|
|
|
|
it("keeps send-only scoped schemas small", () => {
|
|
const sendOnlyPlugin = createChannelPlugin({
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram send plugin.",
|
|
actions: ["send"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "telegram", source: "test", plugin: sendOnlyPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "telegram",
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
|
|
expect(getActionEnum(properties)).toEqual(["send"]);
|
|
expect(properties).toHaveProperty("message");
|
|
expect(properties).toHaveProperty("target");
|
|
expect(properties).toHaveProperty("media");
|
|
expect(properties).not.toHaveProperty("pollId");
|
|
expect(properties).not.toHaveProperty("messageId");
|
|
expect(properties).not.toHaveProperty("channelId");
|
|
expect(properties).not.toHaveProperty("activityName");
|
|
expect(properties).not.toHaveProperty("eventName");
|
|
});
|
|
|
|
it("prunes fields for action groups that discovery does not advertise", () => {
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord test plugin.",
|
|
actions: [
|
|
"send",
|
|
"read",
|
|
"react",
|
|
"reactions",
|
|
"edit",
|
|
"delete",
|
|
"pin",
|
|
"unpin",
|
|
"list-pins",
|
|
"thread-create",
|
|
"thread-list",
|
|
"thread-reply",
|
|
"upload-file",
|
|
],
|
|
});
|
|
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "discord",
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
|
|
expect(properties).toHaveProperty("message");
|
|
expect(properties).toHaveProperty("messageId");
|
|
expect(properties).toHaveProperty("threadName");
|
|
|
|
expect(properties).not.toHaveProperty("topic");
|
|
expect(properties).not.toHaveProperty("rateLimitPerUser");
|
|
expect(properties).not.toHaveProperty("clearParent");
|
|
expect(properties).not.toHaveProperty("activityName");
|
|
expect(properties).not.toHaveProperty("activityState");
|
|
expect(properties).not.toHaveProperty("status");
|
|
expect(properties).not.toHaveProperty("pollId");
|
|
expect(properties).not.toHaveProperty("eventName");
|
|
});
|
|
|
|
it.each<{
|
|
action: ChannelMessageActionName;
|
|
fields: string[];
|
|
}>([
|
|
{ action: "search", fields: ["query", "limit"] },
|
|
{ action: "reactions", fields: ["messageId", "limit"] },
|
|
{ action: "sticker-search", fields: ["query", "limit"] },
|
|
{ action: "emoji-list", fields: ["guildId", "limit"] },
|
|
{ action: "emoji-upload", fields: ["guildId", "emojiName", "media", "roleIds"] },
|
|
{
|
|
action: "sticker-upload",
|
|
fields: ["guildId", "stickerName", "stickerDesc", "stickerTags", "media"],
|
|
},
|
|
{ action: "voice-status", fields: ["guildId", "userId"] },
|
|
{ action: "timeout", fields: ["guildId", "userId", "durationMin", "until", "reason"] },
|
|
{ action: "download-file", fields: ["fileId", "channelId", "threadId"] },
|
|
{ action: "thread-create", fields: ["messageId", "threadName", "channelId"] },
|
|
{ action: "renameGroup", fields: ["name"] },
|
|
{ action: "setGroupIcon", fields: ["name", "filename", "buffer"] },
|
|
{ action: "channel-info", fields: ["channelId", "pageSize", "pageToken"] },
|
|
{ action: "channel-list", fields: ["query", "limit"] },
|
|
])("keeps fields consumed by scoped $action handlers", ({ action, fields }) => {
|
|
const plugin = createChannelPlugin({
|
|
id: "test-channel",
|
|
label: "Test Channel",
|
|
docsPath: "/channels/test-channel",
|
|
blurb: "Scoped schema contract plugin.",
|
|
actions: [action],
|
|
});
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "test-channel", source: "test", plugin }]),
|
|
);
|
|
|
|
const properties = getToolProperties(
|
|
createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "test-channel",
|
|
}),
|
|
);
|
|
|
|
for (const field of fields) {
|
|
expect(properties, `${action} should advertise ${field}`).toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
it("describes the send payload contract on the action and message fields", () => {
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord test plugin.",
|
|
actions: ["send", "channel-info"],
|
|
});
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
|
|
const properties = getToolProperties(
|
|
createMessageTool({ config: {} as never, currentChannelProvider: "discord" }),
|
|
);
|
|
|
|
expect((properties.action as { description?: string }).description).toContain(
|
|
'For action="send"',
|
|
);
|
|
expect((properties.message as { description?: string }).description).toContain(
|
|
"A send needs message",
|
|
);
|
|
});
|
|
|
|
it("filters scoped schemas through the per-agent message action allowlist", () => {
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord test plugin.",
|
|
actions: ["send", "read", "react", "delete"],
|
|
});
|
|
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
|
|
const tool = createMessageTool({
|
|
config: {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "sandbox",
|
|
tools: {
|
|
message: {
|
|
actions: {
|
|
allow: ["send"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as never,
|
|
currentChannelProvider: "discord",
|
|
agentId: "sandbox",
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
|
|
expect(getActionEnum(properties)).toEqual(["send"]);
|
|
expect(properties).toHaveProperty("message");
|
|
expect(properties).toHaveProperty("target");
|
|
expect(properties).not.toHaveProperty("messageId");
|
|
expect(tool.description).toContain("Supports actions: send.");
|
|
expect(tool.description).not.toContain("react");
|
|
});
|
|
|
|
it("preserves channel-management params for scoped channel-move and category-delete allowlists", () => {
|
|
// Regression: SCOPED_ACTION_GROUPS previously omitted channel-move and
|
|
// category-delete from the channel-management group, so narrowing an agent
|
|
// allowlist to either action stripped position/parentId/categoryId from
|
|
// the schema even though the Discord handlers require them.
|
|
const plugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord test plugin.",
|
|
actions: ["send", "channel-move", "category-delete"],
|
|
});
|
|
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "discord", source: "test", plugin }]));
|
|
|
|
const channelMoveTool = createMessageTool({
|
|
config: {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "mover",
|
|
tools: { message: { actions: { allow: ["channel-move"] } } },
|
|
},
|
|
],
|
|
},
|
|
} as never,
|
|
currentChannelProvider: "discord",
|
|
agentId: "mover",
|
|
});
|
|
const channelMoveProps = getToolProperties(channelMoveTool);
|
|
expect(getActionEnum(channelMoveProps)).toEqual(["channel-move"]);
|
|
expect(channelMoveProps).toHaveProperty("position");
|
|
expect(channelMoveProps).toHaveProperty("parentId");
|
|
|
|
const categoryDeleteTool = createMessageTool({
|
|
config: {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "purger",
|
|
tools: { message: { actions: { allow: ["category-delete"] } } },
|
|
},
|
|
],
|
|
},
|
|
} as never,
|
|
currentChannelProvider: "discord",
|
|
agentId: "purger",
|
|
});
|
|
const categoryDeleteProps = getToolProperties(categoryDeleteTool);
|
|
expect(getActionEnum(categoryDeleteProps)).toEqual(["category-delete"]);
|
|
expect(categoryDeleteProps).toHaveProperty("categoryId");
|
|
});
|
|
|
|
it("uses discovery account scope for other configured channel actions", () => {
|
|
const currentPlugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord test plugin.",
|
|
actions: ["send"],
|
|
});
|
|
const scopedOtherPlugin = createChannelPlugin({
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram test plugin.",
|
|
describeMessageTool: ({ accountId }) => ({
|
|
actions: accountId === "ops" ? ["react"] : [],
|
|
}),
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "discord", source: "test", plugin: currentPlugin },
|
|
{ pluginId: "telegram", source: "test", plugin: scopedOtherPlugin },
|
|
]),
|
|
);
|
|
|
|
const scopedTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "discord",
|
|
agentAccountId: "ops",
|
|
});
|
|
const unscopedTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "discord",
|
|
});
|
|
|
|
expect(getActionEnum(getToolProperties(scopedTool))).toContain("react");
|
|
expect(getActionEnum(getToolProperties(unscopedTool))).not.toContain("react");
|
|
expect(scopedTool.description).toContain("Supports actions: react, send.");
|
|
expect(unscopedTool.description).toContain("Supports actions: send.");
|
|
expect(scopedTool.description).not.toContain("telegram (");
|
|
expect(unscopedTool.description).not.toContain("telegram (");
|
|
});
|
|
|
|
it("routes full discovery context into plugin action discovery", () => {
|
|
const seenContexts: Record<string, unknown>[] = [];
|
|
const contextPlugin = createChannelPlugin({
|
|
id: "discord",
|
|
label: "Discord",
|
|
docsPath: "/channels/discord",
|
|
blurb: "Discord context plugin.",
|
|
describeMessageTool: (ctx) => {
|
|
seenContexts.push({ phase: "describeMessageTool", ...ctx });
|
|
return {
|
|
actions: ["send", "react"],
|
|
capabilities: ["presentation"],
|
|
};
|
|
},
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "discord", source: "test", plugin: contextPlugin }]),
|
|
);
|
|
|
|
createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "channel:123",
|
|
currentThreadTs: "thread-456",
|
|
currentMessageId: "msg-789",
|
|
agentAccountId: "ops",
|
|
agentSessionKey: "agent:alpha:main",
|
|
sessionId: "session-123",
|
|
requesterSenderId: "user-42",
|
|
});
|
|
|
|
const context = seenContexts.find((item) => item.phase === "describeMessageTool");
|
|
if (!context) {
|
|
throw new Error("Expected describeMessageTool discovery context");
|
|
}
|
|
expect(context.currentChannelProvider).toBe("discord");
|
|
expect(context.currentChannelId).toBe("channel:123");
|
|
expect(context.currentThreadTs).toBe("thread-456");
|
|
expect(context.currentMessageId).toBe("msg-789");
|
|
expect(context?.accountId).toBe("ops");
|
|
expect(context?.sessionKey).toBe("agent:alpha:main");
|
|
expect(context?.sessionId).toBe("session-123");
|
|
expect(context?.agentId).toBe("alpha");
|
|
expect(context?.requesterSenderId).toBe("user-42");
|
|
});
|
|
|
|
it("passes sender ownership into plugin action discovery", () => {
|
|
const seenContexts: Record<string, unknown>[] = [];
|
|
const plugin = createChannelPlugin({
|
|
id: "matrix",
|
|
label: "Matrix",
|
|
docsPath: "/channels/matrix",
|
|
blurb: "Matrix plugin.",
|
|
describeMessageTool: (ctx) => {
|
|
seenContexts.push(ctx);
|
|
return {
|
|
actions: ["send", "set-profile"],
|
|
};
|
|
},
|
|
});
|
|
|
|
setActivePluginRegistry(createTestRegistry([{ pluginId: "matrix", source: "test", plugin }]));
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "matrix",
|
|
senderIsOwner: true,
|
|
});
|
|
|
|
expect(getActionEnum(getToolProperties(tool))).toContain("set-profile");
|
|
expect(seenContexts.some((ctx) => ctx.senderIsOwner === true)).toBe(true);
|
|
});
|
|
|
|
it("keeps core send and broadcast actions in unscoped schemas", () => {
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
});
|
|
|
|
const actionEnum = getActionEnum(getToolProperties(tool));
|
|
expect(actionEnum).toContain("send");
|
|
expect(actionEnum).toContain("broadcast");
|
|
});
|
|
|
|
it("advertises Slack download-file fileId in scoped schemas", () => {
|
|
const slackFilePlugin = createChannelPlugin({
|
|
id: "slack",
|
|
label: "Slack",
|
|
docsPath: "/channels/slack",
|
|
blurb: "Slack test plugin.",
|
|
actions: ["download-file"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackFilePlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "slack",
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
|
|
expect(getActionEnum(properties)).toContain("download-file");
|
|
expectStringSchema(properties.fileId);
|
|
});
|
|
|
|
it("advertises messageId for read actions", () => {
|
|
const slackReadPlugin = createChannelPlugin({
|
|
id: "slack",
|
|
label: "Slack",
|
|
docsPath: "/channels/slack",
|
|
blurb: "Slack test plugin.",
|
|
actions: ["read"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackReadPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "slack",
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
|
|
expect(getActionEnum(properties)).toContain("read");
|
|
expectStringSchema(properties.messageId, {
|
|
description: "Target read/react/edit/delete/pin/unpin id; reactions default current inbound.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("message tool description", () => {
|
|
afterEach(() => {
|
|
setActivePluginRegistry(createTestRegistry([]));
|
|
});
|
|
|
|
const imessagePlugin = createChannelPlugin({
|
|
id: "imessage",
|
|
label: "iMessage",
|
|
docsPath: "/channels/imessage",
|
|
blurb: "iMessage test plugin.",
|
|
describeMessageTool: ({ currentChannelId }) => {
|
|
const all: ChannelMessageActionName[] = [
|
|
"react",
|
|
"renameGroup",
|
|
"addParticipant",
|
|
"removeParticipant",
|
|
"leaveGroup",
|
|
];
|
|
const lowered = currentChannelId?.toLowerCase() ?? "";
|
|
const isDmTarget =
|
|
lowered.includes("chat_guid:imessage;-;") || lowered.includes("chat_guid:sms;-;");
|
|
return {
|
|
actions: isDmTarget
|
|
? all.filter(
|
|
(action) =>
|
|
action !== "renameGroup" &&
|
|
action !== "addParticipant" &&
|
|
action !== "removeParticipant" &&
|
|
action !== "leaveGroup",
|
|
)
|
|
: all,
|
|
};
|
|
},
|
|
messaging: {
|
|
normalizeTarget: (raw) => {
|
|
const trimmed = raw.trim().replace(/^imessage:/i, "");
|
|
const lower = trimmed.toLowerCase();
|
|
if (lower.startsWith("chat_guid:")) {
|
|
const guid = trimmed.slice("chat_guid:".length);
|
|
const parts = guid.split(";");
|
|
if (parts.length === 3 && parts[1] === "-") {
|
|
return parts[2]?.trim() || trimmed;
|
|
}
|
|
return `chat_guid:${guid}`;
|
|
}
|
|
return trimmed;
|
|
},
|
|
},
|
|
});
|
|
|
|
it("surfaces explicit cross-channel target syntax in the target schema", () => {
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
const target = properties.target as { description?: string } | undefined;
|
|
|
|
expect(target?.description).toContain(
|
|
"Discord/Slack/Mattermost <channelId|user:ID|channel:ID>",
|
|
);
|
|
expect(target?.description).toContain("Telegram chat id/@username");
|
|
});
|
|
|
|
it("describes userId as required directly for member-info, not via target", () => {
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
});
|
|
const properties = getToolProperties(tool);
|
|
const userId = properties.userId as { description?: string } | undefined;
|
|
|
|
expect(userId?.description).toMatch(/member-info/i);
|
|
expect(userId?.description).toMatch(/not.*target|does not accept.*target/i);
|
|
});
|
|
|
|
it("hides iMessage group actions for DM targets", () => {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "imessage", source: "test", plugin: imessagePlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "imessage",
|
|
currentChannelId: "imessage:chat_guid:iMessage;-;+15551234567",
|
|
});
|
|
|
|
expect(tool.description).not.toContain("renameGroup");
|
|
expect(tool.description).not.toContain("addParticipant");
|
|
expect(tool.description).not.toContain("removeParticipant");
|
|
expect(tool.description).not.toContain("leaveGroup");
|
|
});
|
|
|
|
it("describes accepted actions without channel-specific wording when currentChannel is set", () => {
|
|
const signalPlugin = createChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/channels/signal",
|
|
blurb: "Signal test plugin.",
|
|
actions: ["send", "react"],
|
|
});
|
|
|
|
const telegramPluginFull = createChannelPlugin({
|
|
id: "telegram",
|
|
label: "Telegram",
|
|
docsPath: "/channels/telegram",
|
|
blurb: "Telegram test plugin.",
|
|
actions: ["send", "react", "delete", "edit", "topic-create"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "signal", source: "test", plugin: signalPlugin },
|
|
{ pluginId: "telegram", source: "test", plugin: telegramPluginFull },
|
|
]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "signal",
|
|
});
|
|
|
|
expect(tool.description).toContain(
|
|
"Supports actions: delete, edit, react, send, topic-create.",
|
|
);
|
|
expect(tool.description).not.toContain("Current channel");
|
|
expect(tool.description).not.toContain("Other configured channels");
|
|
expect(tool.description).not.toContain("telegram (");
|
|
});
|
|
|
|
it("does not advertise cross-channel actions whose params are hidden by current-channel schema", () => {
|
|
const signalPlugin = createChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/channels/signal",
|
|
blurb: "Signal test plugin.",
|
|
actions: ["send", "react"],
|
|
});
|
|
const matrixProfilePlugin = createChannelPlugin({
|
|
id: "matrix",
|
|
label: "Matrix",
|
|
docsPath: "/channels/matrix",
|
|
blurb: "Matrix test plugin.",
|
|
actions: ["send", "set-profile"],
|
|
toolSchema: {
|
|
properties: {
|
|
displayName: Type.Optional(Type.String()),
|
|
avatarUrl: Type.Optional(Type.String()),
|
|
},
|
|
},
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{ pluginId: "signal", source: "test", plugin: signalPlugin },
|
|
{ pluginId: "matrix", source: "test", plugin: matrixProfilePlugin },
|
|
]),
|
|
);
|
|
|
|
const crossChannelTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "signal",
|
|
});
|
|
const crossChannelProperties = getToolProperties(crossChannelTool);
|
|
|
|
expect(getActionEnum(crossChannelProperties)).not.toContain("set-profile");
|
|
expect(crossChannelProperties.displayName).toBeUndefined();
|
|
expect(crossChannelProperties.avatarUrl).toBeUndefined();
|
|
expect(crossChannelTool.description).not.toContain("matrix (send, set-profile)");
|
|
|
|
const currentChannelTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "matrix",
|
|
});
|
|
const currentChannelProperties = getToolProperties(currentChannelTool);
|
|
|
|
expect(getActionEnum(currentChannelProperties)).toContain("set-profile");
|
|
expect(currentChannelProperties).toHaveProperty("displayName");
|
|
expect(currentChannelProperties).toHaveProperty("avatarUrl");
|
|
});
|
|
|
|
it("normalizes channel aliases before building the current channel description", () => {
|
|
const signalPlugin = createChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/channels/signal",
|
|
blurb: "Signal test plugin.",
|
|
aliases: ["sig"],
|
|
actions: ["send", "react"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "sig",
|
|
});
|
|
|
|
expect(tool.description).toContain("Supports actions: react, send.");
|
|
expect(tool.description).not.toContain("Current channel");
|
|
});
|
|
|
|
it("keeps the current-channel description stable when only one channel is configured", () => {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "imessage", source: "test", plugin: imessagePlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "imessage",
|
|
});
|
|
|
|
expect(tool.description).toContain("Supports actions:");
|
|
expect(tool.description).not.toContain("Current channel");
|
|
expect(tool.description).not.toContain("Other configured channels");
|
|
});
|
|
|
|
it("includes the thread read hint when the current channel supports read", () => {
|
|
const signalPlugin = createChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/channels/signal",
|
|
blurb: "Signal test plugin.",
|
|
actions: ["send", "read", "react"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "signal",
|
|
});
|
|
|
|
expect(tool.description).toContain('action="read" + threadId');
|
|
});
|
|
|
|
it("omits the thread read hint when the current channel does not support read", () => {
|
|
const signalPlugin = createChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/channels/signal",
|
|
blurb: "Signal test plugin.",
|
|
actions: ["send", "react"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "signal",
|
|
});
|
|
|
|
expect(tool.description).not.toContain('action="read" + threadId');
|
|
});
|
|
|
|
it("includes the thread read hint in the generic fallback when configured actions include read", () => {
|
|
const signalPlugin = createChannelPlugin({
|
|
id: "signal",
|
|
label: "Signal",
|
|
docsPath: "/channels/signal",
|
|
blurb: "Signal test plugin.",
|
|
actions: ["read"],
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "signal", source: "test", plugin: signalPlugin }]),
|
|
);
|
|
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(tool.description).toContain("Supports actions:");
|
|
expect(tool.description).toContain('action="read" + threadId');
|
|
});
|
|
|
|
it("includes broadcast in the generic fallback description", () => {
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(tool.description).toContain("Supports actions: broadcast, send.");
|
|
});
|
|
});
|
|
|
|
describe("message tool reasoning tag sanitization", () => {
|
|
it.each([
|
|
{
|
|
field: "text",
|
|
input: "<think>internal reasoning</think>Hello!",
|
|
expected: "Hello!",
|
|
target: "signal:+15551234567",
|
|
channel: "signal",
|
|
},
|
|
{
|
|
field: "content",
|
|
input: "<think>reasoning here</think>Reply text",
|
|
expected: "Reply text",
|
|
target: "discord:123",
|
|
channel: "discord",
|
|
},
|
|
{
|
|
field: "text",
|
|
input: "Normal message without any tags",
|
|
expected: "Normal message without any tags",
|
|
target: "signal:+15551234567",
|
|
channel: "signal",
|
|
},
|
|
{
|
|
field: "message",
|
|
input: "Thinking...\nI'll check that now",
|
|
expected: "Thinking...\nI'll check that now",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
{
|
|
field: "message",
|
|
input: "Thinking\n_internal plan_\n\nVisible answer",
|
|
expected: "Visible answer",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
{
|
|
field: "message",
|
|
input: "Thinking\n_internal plan_\n_more internal notes_",
|
|
expected: "",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
{
|
|
field: "message",
|
|
input: "Reasoning:\n_internal plan_\n\nVisible answer",
|
|
expected: "Visible answer",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
])(
|
|
"sanitizes reasoning tags in $field before sending",
|
|
async ({ channel, target, field, input, expected }) => {
|
|
mockSendResult({ channel, to: target });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target,
|
|
[field]: input,
|
|
},
|
|
});
|
|
expect(call?.params?.[field]).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("sanitizes visible presentation text before sending", async () => {
|
|
mockSendResult({ channel: "slack", to: "slack:C123" });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "slack:C123",
|
|
presentation: {
|
|
title: "<think>internal title</think>Deploy ready",
|
|
blocks: [
|
|
{ type: "text", text: "<think>internal note</think>Ship it" },
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "<think>button rationale</think>Approve",
|
|
action: { type: "command", command: "/codex approve" },
|
|
value: "approve",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "<think>selection rationale</think>Pick a lane",
|
|
options: [
|
|
{
|
|
label: "<think>option rationale</think>Main",
|
|
value: "main",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "chart",
|
|
chartType: "line",
|
|
title: "<think>chart rationale</think>Latency",
|
|
categories: ["<think>category rationale</think>Monday"],
|
|
series: [
|
|
{
|
|
name: "<think>series rationale</think>p95",
|
|
values: [250],
|
|
},
|
|
],
|
|
xLabel: "<think>axis rationale</think>Day",
|
|
yLabel: "<think>axis rationale</think>Milliseconds",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.presentation).toEqual({
|
|
title: "Deploy ready",
|
|
blocks: [
|
|
{ type: "text", text: "Ship it" },
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: "Approve",
|
|
action: { type: "command", command: "/codex approve" },
|
|
value: "approve",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "Pick a lane",
|
|
options: [{ label: "Main", value: "main" }],
|
|
},
|
|
{
|
|
type: "chart",
|
|
chartType: "line",
|
|
title: "Latency",
|
|
categories: ["Monday"],
|
|
series: [{ name: "p95", values: [250] }],
|
|
xLabel: "Day",
|
|
yLabel: "Milliseconds",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("strips internal runtime context from visible presentation fields before sending (#53732)", async () => {
|
|
mockSendResult({ channel: "slack", to: "slack:C123" });
|
|
|
|
const internalContext =
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "slack:C123",
|
|
presentation: {
|
|
title: `Deploy ready\n${internalContext}`,
|
|
blocks: [
|
|
{ type: "text", text: `Ship it\n${internalContext}` },
|
|
{
|
|
type: "input",
|
|
placeholder: `Pick a lane\n${internalContext}`,
|
|
},
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{
|
|
label: `Approve\n${internalContext}`,
|
|
value: "approve",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "select",
|
|
options: [
|
|
{
|
|
label: `Main\n${internalContext}`,
|
|
value: "main",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.presentation).toEqual({
|
|
title: "Deploy ready",
|
|
blocks: [
|
|
{ type: "text", text: "Ship it" },
|
|
{ type: "input", placeholder: "Pick a lane" },
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Approve", value: "approve" }],
|
|
},
|
|
{
|
|
type: "select",
|
|
options: [{ label: "Main", value: "main" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("sanitizes mixed-case table captions, headers, and string cells", async () => {
|
|
mockSendResult({ channel: "slack", to: "slack:C123" });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "slack:C123",
|
|
presentation: {
|
|
blocks: [
|
|
{
|
|
type: "Table",
|
|
caption: " <think>caption rationale</think>Pipeline report ",
|
|
headers: [" <think>header rationale</think>Account ", " ARR "],
|
|
rows: [
|
|
[" <think>cell rationale</think>Acme ", 125000],
|
|
[" Globex ", 82000],
|
|
],
|
|
rowHeaderColumnIndex: 0,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "Table",
|
|
caption: "Pipeline report",
|
|
headers: ["Account", "ARR"],
|
|
rows: [
|
|
["Acme", 125000],
|
|
["Globex", 82000],
|
|
],
|
|
rowHeaderColumnIndex: 0,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("message tool boot-echo guard", () => {
|
|
const longBootPrompt = [
|
|
"You are running a boot check. Follow BOOT.md instructions exactly.",
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
|
|
"This context is runtime-generated, not user-authored. Keep internal details private.",
|
|
"",
|
|
"BOOT.md:",
|
|
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel and report the active project status with three concrete bullet points.",
|
|
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
|
"If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).",
|
|
].join("\n");
|
|
|
|
let setBootEchoContextForSession: typeof import("../../gateway/boot-echo-guard.js").setBootEchoContextForSession;
|
|
let clearBootEchoContextForSession: typeof import("../../gateway/boot-echo-guard.js").clearBootEchoContextForSession;
|
|
|
|
beforeAll(async () => {
|
|
({ setBootEchoContextForSession, clearBootEchoContextForSession } =
|
|
await import("../../gateway/boot-echo-guard.js"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearBootEchoContextForSession("agent:main");
|
|
});
|
|
|
|
it("suppresses text-only sends that echo a substantial chunk of the registered boot prompt without preserving the wrapper markers (#53732)", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
|
|
// The model is paraphrasing out the wrapper but copying the BOOT.md
|
|
// sentence verbatim — exactly the leak vector clawsweeper called out
|
|
// on #75128 that the marker-only strip would miss.
|
|
const echoedText =
|
|
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const { call, result } = await executeSendWithResult({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: echoedText,
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call).toBeUndefined();
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
expect(result.details).toMatchObject({
|
|
status: "suppressed",
|
|
reason: "internal_runtime_context_echo",
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("thoughtful greeting");
|
|
});
|
|
|
|
it("sanitizes boot echo text and still sends when media content remains", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const echoedText =
|
|
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: echoedText,
|
|
mediaUrl: "file:///tmp/status.png",
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.text).toBe("");
|
|
expect(call?.params?.mediaUrl).toBe("file:///tmp/status.png");
|
|
});
|
|
|
|
it("sanitizes boot echo text and still sends when snake_case media content remains", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const echoedText =
|
|
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: echoedText,
|
|
media_url: "file:///tmp/status.png",
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.text).toBe("");
|
|
expect(call?.params?.media_url).toBe("file:///tmp/status.png");
|
|
});
|
|
|
|
it("sanitizes boot echo text and still sends when snake_case media arrays remain", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const echoedText =
|
|
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: echoedText,
|
|
media_urls: ["file:///tmp/one.png", "file:///tmp/two.png"],
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.text).toBe("");
|
|
expect(call?.params?.media_urls).toEqual(["file:///tmp/one.png", "file:///tmp/two.png"]);
|
|
});
|
|
|
|
it("sanitizes boot echo text and still sends when structured attachments remain", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const echoedText =
|
|
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
message: echoedText,
|
|
attachments: [{ media: "file:///tmp/status.png" }],
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.message).toBe("");
|
|
expect(call?.params?.attachments).toEqual([{ media: "file:///tmp/status.png" }]);
|
|
});
|
|
|
|
it("sanitizes boot echo text and still sends when structured attachment aliases remain", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const echoedText =
|
|
"Here is what I was told: When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
message: echoedText,
|
|
attachments: [{ file_path: "/tmp/status.png" }],
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.message).toBe("");
|
|
expect(call?.params?.attachments).toEqual([{ file_path: "/tmp/status.png" }]);
|
|
});
|
|
|
|
it("preserves a short legitimate BOOT.md-directed send that does not reproduce a long boot-prompt chunk", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: "Good morning! Project status looks healthy today.",
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.text).toBe("Good morning! Project status looks healthy today.");
|
|
});
|
|
|
|
it("does not affect outbound text when no boot prompt is registered for the session", async () => {
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: "Any message goes through unchanged.",
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
expect(call?.params?.text).toBe("Any message goes through unchanged.");
|
|
});
|
|
|
|
it("collapses presentation fields that echo a substantial chunk of the registered boot prompt (#53732)", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "slack", to: "slack:C123" });
|
|
|
|
const echoedBootText =
|
|
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "slack:C123",
|
|
mediaUrl: "file:///tmp/proof.png",
|
|
presentation: {
|
|
title: echoedBootText,
|
|
blocks: [
|
|
{ type: "text", text: echoedBootText },
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: echoedBootText, value: "approve" }],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: echoedBootText,
|
|
options: [{ label: echoedBootText, value: "main" }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
|
|
expect(call?.params?.presentation).toEqual({
|
|
title: "",
|
|
blocks: [
|
|
{ type: "text", text: "" },
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "", value: "approve" }],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "",
|
|
options: [{ label: "", value: "main" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("sanitizes boot echo text from presentation button links before dispatch", async () => {
|
|
setBootEchoContextForSession("agent:main", longBootPrompt);
|
|
mockSendResult({ channel: "slack", to: "slack:C123" });
|
|
|
|
const echoedText =
|
|
"When you wake up each morning, send a thoughtful greeting to the operator over the configured channel and report the active project status";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "slack:C123",
|
|
message: "Visible",
|
|
presentation: {
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{ label: "Status", url: echoedText },
|
|
{ label: "App", webApp: { url: echoedText }, web_app: { url: echoedText } },
|
|
{
|
|
label: "Typed status",
|
|
action: { type: "url", url: echoedText },
|
|
value: "must-not-become-active",
|
|
},
|
|
{
|
|
label: "Typed app",
|
|
action: { type: "web-app", url: echoedText },
|
|
url: "https://legacy.example.test",
|
|
},
|
|
{
|
|
label: "Hosted app",
|
|
action: {
|
|
type: "web-app",
|
|
url: echoedText,
|
|
widgetId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
toolOptions: { agentSessionKey: "agent:main" },
|
|
});
|
|
|
|
expect(call?.params?.message).toBe("Visible");
|
|
expect(call?.params?.presentation).toEqual({
|
|
blocks: [
|
|
{
|
|
type: "buttons",
|
|
buttons: [
|
|
{ label: "Status" },
|
|
{ label: "App" },
|
|
{ label: "Typed status" },
|
|
{ label: "Typed app" },
|
|
{
|
|
label: "Hosted app",
|
|
action: { type: "web-app", widgetId: "AAAAAAAAAAAAAAAAAAAAAA" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("message tool internal-runtime-context sanitization", () => {
|
|
it.each([
|
|
{
|
|
field: "text",
|
|
input:
|
|
"Here is the boot info:\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nThis context is runtime-generated, not user-authored. Keep internal details private.\n\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nDone.",
|
|
expected: "Here is the boot info:\n\nDone.",
|
|
target: "signal:+15551234567",
|
|
channel: "signal",
|
|
},
|
|
{
|
|
field: "content",
|
|
input:
|
|
"Before\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nleaked\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nAfter",
|
|
expected: "Before\n\nAfter",
|
|
target: "discord:123",
|
|
channel: "discord",
|
|
},
|
|
{
|
|
field: "message",
|
|
input:
|
|
"Here is the boot info:\\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\\nBOOT.md:\\nWake up and report.\\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\\nDone.",
|
|
expected: "Here is the boot info:\n\nDone.",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
{
|
|
field: "SendMessage",
|
|
input:
|
|
"Alias\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nDone.",
|
|
expected: "Alias\n\nDone.",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
])(
|
|
"strips internal-runtime-context blocks in $field before sending so verbatim boot-prompt echoes do not leak (#53732)",
|
|
async ({ channel, target, field, input, expected }) => {
|
|
mockSendResult({ channel, to: target });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target,
|
|
[field]: input,
|
|
},
|
|
});
|
|
expect(call?.params?.[field]).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("strips inbound metadata and delivery hints from outbound message text before dispatch (#89100)", async () => {
|
|
mockSendResult({ channel: "signal", to: "signal:group-1" });
|
|
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "signal:group-1",
|
|
message: [
|
|
"Delivery: Final assistant text is not automatically delivered in this run. Use the `message` tool to send user-visible output.",
|
|
"",
|
|
"Conversation info (untrusted metadata):",
|
|
"```json",
|
|
'{"chat_id":"group:abc","sender_id":"+15551234567","is_group_chat":true}',
|
|
"```",
|
|
"",
|
|
"Sender (untrusted metadata):",
|
|
"```json",
|
|
'{"label":"Bob (+15551234567)","id":"+15551234567"}',
|
|
"```",
|
|
"",
|
|
"Visible reply only.",
|
|
].join("\n"),
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.message).toBe("Visible reply only.");
|
|
expect(JSON.stringify(call?.params)).not.toContain("sender_id");
|
|
expect(JSON.stringify(call?.params)).not.toContain("+15551234567");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "delivery hint only",
|
|
message:
|
|
"Delivery: Final assistant text is not automatically delivered in this run. Use the `message` tool to send user-visible output.",
|
|
},
|
|
{
|
|
name: "narration-aware delivery hint only",
|
|
message: MESSAGE_TOOL_ONLY_DELIVERY_HINT,
|
|
},
|
|
{
|
|
name: "room-event delivery hint only",
|
|
message: ROOM_EVENT_DELIVERY_HINT,
|
|
},
|
|
{
|
|
name: "inbound metadata only",
|
|
message: [
|
|
"Conversation info (untrusted metadata):",
|
|
"```json",
|
|
'{"chat_id":"group:abc","sender_id":"+15551234567"}',
|
|
"```",
|
|
].join("\n"),
|
|
},
|
|
])("suppresses outbound sends that contain only $name (#89100)", async ({ message }) => {
|
|
const { call, result } = await executeSendWithResult({
|
|
action: {
|
|
target: "signal:group-1",
|
|
message,
|
|
},
|
|
});
|
|
|
|
expect(call).toBeUndefined();
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
expect(result.details).toMatchObject({
|
|
status: "suppressed",
|
|
reason: "inbound_metadata_echo",
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("sender_id");
|
|
expect(JSON.stringify(result)).not.toContain("+15551234567");
|
|
});
|
|
|
|
it("preserves legitimate outbound messages that start with timestamp-like text", async () => {
|
|
mockSendResult({ channel: "signal", to: "signal:group-1" });
|
|
|
|
const message = "[Wed 2026-03-11 23:51 PDT] Standup starts now";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "signal:group-1",
|
|
message,
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.message).toBe(message);
|
|
});
|
|
|
|
it("strips internal-runtime-context blocks from poll creation text before dispatch", async () => {
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const internalContext =
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
|
|
const call = await executeSend({
|
|
action: {
|
|
action: "poll",
|
|
target: "telegram:123",
|
|
pollQuestion: `Choose one\n${internalContext}`,
|
|
pollOption: [`Yes\n${internalContext}`, "No"],
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.pollQuestion).toBe("Choose one");
|
|
expect(call?.params?.pollOption).toEqual(["Yes", "No"]);
|
|
});
|
|
|
|
it("strips internal-runtime-context blocks from quote text before dispatch", async () => {
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const internalContext =
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
message: "Visible",
|
|
quoteText: `Quoted\n${internalContext}`,
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.quoteText).toBe("Quoted");
|
|
});
|
|
|
|
it("parses and sanitizes stringified presentation and interactive payloads before dispatch", async () => {
|
|
mockSendResult({ channel: "slack", to: "slack:C123" });
|
|
|
|
const internalContext =
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "slack:C123",
|
|
message: "Visible",
|
|
presentation: JSON.stringify({
|
|
title: `Presentation\n${internalContext}`,
|
|
blocks: [{ type: "text", text: `Block\n${internalContext}` }],
|
|
}),
|
|
interactive: JSON.stringify({
|
|
blocks: [{ type: "text", text: `Legacy\n${internalContext}` }],
|
|
}),
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.presentation).toEqual({
|
|
title: "Presentation",
|
|
blocks: [{ type: "text", text: "Block" }],
|
|
});
|
|
expect(call?.params?.interactive).toEqual({
|
|
blocks: [{ type: "text", text: "Legacy" }],
|
|
});
|
|
});
|
|
|
|
it("suppresses pure internal-runtime-context sends before generic raw-params logging can see original args", async () => {
|
|
const { call, result } = await executeSendWithResult({
|
|
action: {
|
|
target: "discord:123",
|
|
content:
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
|
|
},
|
|
});
|
|
|
|
expect(call).toBeUndefined();
|
|
expect(mocks.runMessageAction).not.toHaveBeenCalled();
|
|
expect(result.details).toMatchObject({
|
|
status: "suppressed",
|
|
reason: "internal_runtime_context_echo",
|
|
});
|
|
expect(JSON.stringify(result)).not.toContain("BOOT.md");
|
|
expect(JSON.stringify(result)).not.toContain("Wake up and report");
|
|
});
|
|
|
|
it("sanitizes every visible text alias even after an earlier field is fully suppressed", async () => {
|
|
mockSendResult({ channel: "telegram", to: "telegram:123" });
|
|
|
|
const internalOnly =
|
|
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nBOOT.md:\nWake up and report.\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>";
|
|
const call = await executeSend({
|
|
action: {
|
|
target: "telegram:123",
|
|
text: internalOnly,
|
|
message: `Visible\n${internalOnly}`,
|
|
mediaUrl: "file:///tmp/status.png",
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.text).toBe("");
|
|
expect(call?.params?.message).toBe("Visible");
|
|
});
|
|
});
|
|
|
|
describe("message tool sandbox passthrough", () => {
|
|
it.each([
|
|
{
|
|
name: "forwards sandboxRoot to runMessageAction",
|
|
toolOptions: { sandboxRoot: "/tmp/sandbox" },
|
|
expected: "/tmp/sandbox",
|
|
},
|
|
{
|
|
name: "omits sandboxRoot when not configured",
|
|
toolOptions: {},
|
|
expected: undefined,
|
|
},
|
|
])("$name", async ({ toolOptions, expected }) => {
|
|
mockSendResult({ to: "telegram:123" });
|
|
|
|
const call = await executeSend({
|
|
toolOptions,
|
|
action: {
|
|
target: "telegram:123",
|
|
message: "",
|
|
},
|
|
});
|
|
expect(call?.sandboxRoot).toBe(expected);
|
|
});
|
|
|
|
it("does not trust ambient current-turn identity without a capability", async () => {
|
|
mockSendResult({ to: "discord:123" });
|
|
|
|
const call = await executeSend({
|
|
toolOptions: {
|
|
agentId: "main",
|
|
agentSessionKey: "agent:main:runtime-policy",
|
|
runId: "run-1",
|
|
sessionId: "session-1",
|
|
agentAccountId: "forged-account",
|
|
requesterSenderId: "forged-sender",
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "forged-current",
|
|
},
|
|
action: {
|
|
target: "discord:123",
|
|
message: "hi",
|
|
},
|
|
});
|
|
|
|
expect(call?.requesterAccountId).toBeUndefined();
|
|
expect(call?.requesterSenderId).toBeUndefined();
|
|
expect(call?.toolContext).toMatchObject({
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "forged-current",
|
|
});
|
|
expect(call?.messageActionAuthorization).toEqual({
|
|
requesterAccountId: undefined,
|
|
requesterSenderId: undefined,
|
|
toolContext: undefined,
|
|
});
|
|
});
|
|
|
|
it("forwards capability-bound current-turn identity to local actions", async () => {
|
|
mockSendResult({ to: "discord:123" });
|
|
const token = mintMessageActionTurnCapability({
|
|
agentId: "main",
|
|
runId: "run-1",
|
|
sessionKey: "agent:main:runtime-policy",
|
|
sessionId: "session-1",
|
|
requesterAccountId: "trusted-account",
|
|
requesterSenderId: "trusted-sender",
|
|
toolContext: {
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "trusted-current",
|
|
currentChatType: "channel",
|
|
},
|
|
});
|
|
mintedTurnCapabilities.push(token);
|
|
|
|
const call = await executeSend({
|
|
toolOptions: {
|
|
agentId: "main",
|
|
agentSessionKey: "agent:main:runtime-policy",
|
|
runId: "run-1",
|
|
sessionId: "session-1",
|
|
messageActionTurnCapability: token,
|
|
agentAccountId: "forged-account",
|
|
requesterSenderId: "forged-sender",
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "forged-current",
|
|
},
|
|
action: {
|
|
target: "discord:123",
|
|
message: "hi",
|
|
},
|
|
});
|
|
|
|
expect(call?.requesterAccountId).toBe("trusted-account");
|
|
expect(call?.requesterSenderId).toBe("trusted-sender");
|
|
expect(call?.toolContext).toMatchObject({
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "forged-current",
|
|
});
|
|
expect(call?.messageActionAuthorization).toMatchObject({
|
|
requesterAccountId: "trusted-account",
|
|
requesterSenderId: "trusted-sender",
|
|
toolContext: {
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "trusted-current",
|
|
currentChatType: "channel",
|
|
},
|
|
});
|
|
expect(call?.messageActionAuthorization?.toolContext).not.toMatchObject({
|
|
currentChannelId: "forged-current",
|
|
});
|
|
expect(call?.toolContext).toMatchObject({
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "forged-current",
|
|
skipCrossContextDecoration: true,
|
|
});
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|