mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:50:43 +00:00
Thread sourceReplyDeliveryMode into Codex/OpenClaw tool construction and annotate the message tool description for message-tool-only turns so visible replies use message(action=send).\n\nAlso adds focused regression coverage and a changelog entry.
1469 lines
46 KiB
TypeScript
1469 lines
46 KiB
TypeScript
import { Type } from "typebox";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
|
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
|
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.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;
|
|
|
|
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);
|
|
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 } : {}),
|
|
};
|
|
},
|
|
),
|
|
}));
|
|
|
|
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 ?? [];
|
|
}
|
|
|
|
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"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetPluginRuntimeStateForTest();
|
|
mocks.runMessageAction.mockReset();
|
|
mocks.getRuntimeConfig.mockReset().mockReturnValue({});
|
|
mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
|
|
resolvedConfig: config,
|
|
diagnostics: [],
|
|
}));
|
|
mocks.getScopedChannelsCommandSecretTargets.mockClear();
|
|
setActivePluginRegistry(createTestRegistry([]));
|
|
});
|
|
|
|
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;
|
|
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.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 } : {}),
|
|
};
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function executeSend(params: {
|
|
action: Record<string, unknown>;
|
|
toolOptions?: Partial<Parameters<typeof createMessageTool>[0]>;
|
|
}) {
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
...params.toolOptions,
|
|
});
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
...params.action,
|
|
});
|
|
return mocks.runMessageAction.mock.calls[0]?.[0] as
|
|
| {
|
|
params?: Record<string, unknown>;
|
|
sandboxRoot?: string;
|
|
requesterSenderId?: string;
|
|
senderIsOwner?: boolean;
|
|
}
|
|
| undefined;
|
|
}
|
|
|
|
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 replies to the current source conversation must use action="send"',
|
|
);
|
|
expect(scopedTool.description).toContain("target defaults to the current source conversation");
|
|
expect(scopedTool.description).toContain("Normal final answers are private");
|
|
expect(explicitTargetTool.description).toContain("Include target when sending");
|
|
expect(explicitTargetTool.description).not.toContain(
|
|
"target defaults to the current source conversation",
|
|
);
|
|
expect(defaultTool.description).not.toContain(
|
|
"visible replies to the current source conversation",
|
|
);
|
|
});
|
|
|
|
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 replies to the current source conversation must use action="send"',
|
|
);
|
|
});
|
|
|
|
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 = mocks.resolveCommandSecretRefsViaGateway.mock.calls.at(-1)?.[0] as {
|
|
targetIds?: Set<string>;
|
|
allowedPaths?: Set<string>;
|
|
};
|
|
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 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 = mocks.resolveCommandSecretRefsViaGateway.mock.calls.at(-1)?.[0] as {
|
|
config?: unknown;
|
|
targetIds?: Set<string>;
|
|
allowedPaths?: Set<string>;
|
|
};
|
|
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(mocks.runMessageAction.mock.calls[0]?.[0]?.cfg).toBe(resolvedConfig);
|
|
});
|
|
});
|
|
|
|
describe("message tool agent routing", () => {
|
|
it("derives agentId from the session key", async () => {
|
|
mockSendResult();
|
|
|
|
const tool = createMessageTool({
|
|
agentSessionKey: "agent:alpha:main",
|
|
config: {} as never,
|
|
runMessageAction: mocks.runMessageAction as never,
|
|
});
|
|
|
|
await tool.execute("1", {
|
|
action: "send",
|
|
target: "telegram:123",
|
|
message: "hi",
|
|
});
|
|
|
|
const call = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
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",
|
|
config: {} as never,
|
|
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 = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
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",
|
|
config: {} as never,
|
|
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 = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
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 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 = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
expect(call?.toolContext?.currentThreadTs).toBe("111.222");
|
|
expect(call?.toolContext?.replyToMode).toBe("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("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 = mocks.runMessageAction.mock.calls[0]?.[0];
|
|
expect(call?.params?.target).toBe("channel:C999");
|
|
});
|
|
});
|
|
|
|
describe("message tool path passthrough", () => {
|
|
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).toEqual(
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
target: "-1001234567890:topic:42",
|
|
threadId: "42",
|
|
message: "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);
|
|
|
|
expect(properties).toHaveProperty("presentation");
|
|
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("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",
|
|
});
|
|
|
|
expect(seenContexts).toContainEqual(
|
|
expect.objectContaining({
|
|
currentChannelProvider: "discord",
|
|
currentChannelId: "channel:123",
|
|
currentThreadTs: "thread-456",
|
|
currentMessageId: "msg-789",
|
|
accountId: "ops",
|
|
sessionKey: "agent:alpha:main",
|
|
sessionId: "session-123",
|
|
agentId: "alpha",
|
|
requesterSenderId: "user-42",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("forwards senderIsOwner into plugin action discovery", () => {
|
|
const seenContexts: Record<string, unknown>[] = [];
|
|
const ownerAwarePlugin = createChannelPlugin({
|
|
id: "matrix",
|
|
label: "Matrix",
|
|
docsPath: "/channels/matrix",
|
|
blurb: "Matrix owner-aware plugin.",
|
|
describeMessageTool: (ctx) => {
|
|
seenContexts.push(ctx);
|
|
return {
|
|
actions: ctx.senderIsOwner === false ? ["send"] : ["send", "set-profile"],
|
|
};
|
|
},
|
|
});
|
|
|
|
setActivePluginRegistry(
|
|
createTestRegistry([{ pluginId: "matrix", source: "test", plugin: ownerAwarePlugin }]),
|
|
);
|
|
|
|
const ownerTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "matrix",
|
|
senderIsOwner: true,
|
|
});
|
|
const nonOwnerTool = createMessageTool({
|
|
config: {} as never,
|
|
currentChannelProvider: "matrix",
|
|
senderIsOwner: false,
|
|
});
|
|
|
|
expect(getActionEnum(getToolProperties(ownerTool))).toContain("set-profile");
|
|
expect(getActionEnum(getToolProperties(nonOwnerTool))).not.toContain("set-profile");
|
|
expect(seenContexts).toContainEqual(expect.objectContaining({ senderIsOwner: true }));
|
|
expect(seenContexts).toContainEqual(expect.objectContaining({ senderIsOwner: false }));
|
|
});
|
|
|
|
it("keeps core send and broadcast actions in unscoped schemas", () => {
|
|
const tool = createMessageTool({
|
|
config: {} as never,
|
|
});
|
|
|
|
expect(getActionEnum(getToolProperties(tool))).toEqual(
|
|
expect.arrayContaining(["send", "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");
|
|
expect(properties.fileId).toMatchObject({ type: "string" });
|
|
});
|
|
|
|
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");
|
|
expect(properties.messageId).toMatchObject({
|
|
type: "string",
|
|
description: expect.stringContaining("read"),
|
|
});
|
|
});
|
|
});
|
|
|
|
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("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('Use action="read" with 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('Use action="read" with 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('Use action="read" with 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: "Reasoning:\n_internal plan_\n\nVisible answer",
|
|
expected: "Visible answer",
|
|
target: "telegram:123",
|
|
channel: "telegram",
|
|
},
|
|
{
|
|
field: "message",
|
|
input: "Reasoning:\n_internal plan_\n_more internal notes_",
|
|
expected: "",
|
|
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",
|
|
value: "approve",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "<think>selection rationale</think>Pick a lane",
|
|
options: [
|
|
{
|
|
label: "<think>option rationale</think>Main",
|
|
value: "main",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(call?.params?.presentation).toEqual({
|
|
title: "Deploy ready",
|
|
blocks: [
|
|
{ type: "text", text: "Ship it" },
|
|
{
|
|
type: "buttons",
|
|
buttons: [{ label: "Approve", value: "approve" }],
|
|
},
|
|
{
|
|
type: "select",
|
|
placeholder: "Pick a lane",
|
|
options: [{ label: "Main", value: "main" }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
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("forwards trusted requesterSenderId to runMessageAction", async () => {
|
|
mockSendResult({ to: "discord:123" });
|
|
|
|
const call = await executeSend({
|
|
toolOptions: { requesterSenderId: "1234567890" },
|
|
action: {
|
|
target: "discord:123",
|
|
message: "hi",
|
|
},
|
|
});
|
|
|
|
expect(call?.requesterSenderId).toBe("1234567890");
|
|
});
|
|
|
|
it("forwards senderIsOwner to runMessageAction", async () => {
|
|
mockSendResult({ to: "discord:123" });
|
|
|
|
const call = await executeSend({
|
|
toolOptions: { senderIsOwner: false },
|
|
action: {
|
|
target: "discord:123",
|
|
message: "hi",
|
|
},
|
|
});
|
|
|
|
expect(call?.senderIsOwner).toBe(false);
|
|
});
|
|
});
|