refactor: share auto reply helper fixtures

This commit is contained in:
Peter Steinberger 2026-03-13 22:44:24 +00:00
parent fd5243c27e
commit 0201f3ff7b
10 changed files with 133 additions and 136 deletions

View File

@ -9,6 +9,20 @@ const baseParams = {
replyToMode: "off" as const, replyToMode: "off" as const,
}; };
async function expectSameTargetRepliesSuppressed(params: { provider: string; to: string }) {
const { replyPayloads } = await buildReplyPayloads({
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "feishu",
originatingTo: "ou_abc123",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "message", provider: params.provider, to: params.to }],
});
expect(replyPayloads).toHaveLength(0);
}
describe("buildReplyPayloads media filter integration", () => { describe("buildReplyPayloads media filter integration", () => {
it("strips media URL from payload when in messagingToolSentMediaUrls", async () => { it("strips media URL from payload when in messagingToolSentMediaUrls", async () => {
const { replyPayloads } = await buildReplyPayloads({ const { replyPayloads } = await buildReplyPayloads({
@ -142,31 +156,11 @@ describe("buildReplyPayloads media filter integration", () => {
}); });
it("suppresses same-target replies when message tool target provider is generic", async () => { it("suppresses same-target replies when message tool target provider is generic", async () => {
const { replyPayloads } = await buildReplyPayloads({ await expectSameTargetRepliesSuppressed({ provider: "message", to: "ou_abc123" });
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "feishu",
originatingTo: "ou_abc123",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "message", provider: "message", to: "ou_abc123" }],
});
expect(replyPayloads).toHaveLength(0);
}); });
it("suppresses same-target replies when target provider is channel alias", async () => { it("suppresses same-target replies when target provider is channel alias", async () => {
const { replyPayloads } = await buildReplyPayloads({ await expectSameTargetRepliesSuppressed({ provider: "lark", to: "ou_abc123" });
...baseParams,
payloads: [{ text: "hello world!" }],
messageProvider: "heartbeat",
originatingChannel: "feishu",
originatingTo: "ou_abc123",
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "message", provider: "lark", to: "ou_abc123" }],
});
expect(replyPayloads).toHaveLength(0);
}); });
it("drops all final payloads when block pipeline streamed successfully", async () => { it("drops all final payloads when block pipeline streamed successfully", async () => {

View File

@ -2,7 +2,7 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js"; import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js"; import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js"; import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn();
const runWithModelFallbackMock = vi.fn(); const runWithModelFallbackMock = vi.fn();
@ -72,32 +72,15 @@ describe("runReplyAgent media path normalization", () => {
const result = await runReplyAgent({ const result = await runReplyAgent({
commandBody: "generate", commandBody: "generate",
followupRun: { followupRun: createMockFollowupRun({
prompt: "generate", prompt: "generate",
enqueuedAt: Date.now(),
run: { run: {
agentId: "main", agentId: "main",
agentDir: "/tmp/agent", agentDir: "/tmp/agent",
sessionId: "session",
sessionKey: "main",
messageProvider: "telegram", messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace", workspaceDir: "/tmp/workspace",
config: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
}, },
timeoutMs: 1_000, }) as unknown as FollowupRun,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun,
queueKey: "main", queueKey: "main",
resolvedQueue: { mode: "interrupt" } as QueueSettings, resolvedQueue: { mode: "interrupt" } as QueueSettings,
shouldSteer: false, shouldSteer: false,

View File

@ -5,8 +5,8 @@ import {
} from "../../../acp/conversation-id.js"; } from "../../../acp/conversation-id.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js"; import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js"; import type { HandleCommandsParams } from "../commands-types.js";
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
import { resolveTelegramConversationId } from "../telegram-context.js"; import { resolveTelegramConversationId } from "../telegram-context.js";
export function resolveAcpCommandChannel(params: HandleCommandsParams): string { export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
@ -64,19 +64,6 @@ export function resolveAcpCommandConversationId(params: HandleCommandsParams): s
}); });
} }
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeConversationText(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
function parseDiscordParentChannelFromContext(raw: unknown): string | undefined { function parseDiscordParentChannelFromContext(raw: unknown): string | undefined {
const parentId = normalizeConversationText(raw); const parentId = normalizeConversationText(raw);
if (!parentId) { if (!parentId) {

View File

@ -1,10 +1,5 @@
import { getChannelDock } from "../../channels/dock.js"; import { getChannelDock } from "../../channels/dock.js";
import { import { resolveExplicitConfigWriteTarget } from "../../channels/plugins/config-writes.js";
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveExplicitConfigWriteTarget,
} from "../../channels/plugins/config-writes.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js"; import { listPairingChannels } from "../../channels/plugins/pairing.js";
import type { ChannelId } from "../../channels/plugins/types.js"; import type { ChannelId } from "../../channels/plugins/types.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/registry.js";
@ -36,6 +31,7 @@ import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js"; import { rejectUnauthorizedCommand, requireCommandFlagEnabled } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js"; import type { CommandHandler } from "./commands-types.js";
import { resolveConfigWriteDeniedText } from "./config-write-authorization.js";
type AllowlistScope = "dm" | "group" | "all"; type AllowlistScope = "dm" | "group" | "all";
type AllowlistAction = "list" | "add" | "remove"; type AllowlistAction = "list" | "add" | "remove";
@ -628,20 +624,19 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
accountId: normalizedAccountId, accountId: normalizedAccountId,
writeTarget, writeTarget,
} = resolveAccountTarget(parsedConfig, channelId, accountId); } = resolveAccountTarget(parsedConfig, channelId, accountId);
const writeAuth = authorizeConfigWrite({ const deniedText = resolveConfigWriteDeniedText({
cfg: params.cfg, cfg: params.cfg,
origin: { channelId, accountId: params.ctx.AccountId },
target: writeTarget,
allowBypass: canBypassConfigWritePolicy({
channel: params.command.channel, channel: params.command.channel,
channelId,
accountId: params.ctx.AccountId,
gatewayClientScopes: params.ctx.GatewayClientScopes, gatewayClientScopes: params.ctx.GatewayClientScopes,
}), target: writeTarget,
}); });
if (!writeAuth.allowed) { if (deniedText) {
return { return {
shouldContinue: false, shouldContinue: false,
reply: { reply: {
text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }), text: deniedText,
}, },
}; };
} }

View File

@ -1,9 +1,4 @@
import { import { resolveConfigWriteTargetFromPath } from "../../channels/plugins/config-writes.js";
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
resolveConfigWriteTargetFromPath,
} from "../../channels/plugins/config-writes.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/registry.js";
import { import {
getConfigValueAtPath, getConfigValueAtPath,
@ -31,6 +26,7 @@ import {
} from "./command-gates.js"; } from "./command-gates.js";
import type { CommandHandler } from "./commands-types.js"; import type { CommandHandler } from "./commands-types.js";
import { parseConfigCommand } from "./config-commands.js"; import { parseConfigCommand } from "./config-commands.js";
import { resolveConfigWriteDeniedText } from "./config-write-authorization.js";
import { parseDebugCommand } from "./debug-commands.js"; import { parseDebugCommand } from "./debug-commands.js";
export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => { export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => {
@ -84,20 +80,19 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
} }
parsedWritePath = parsedPath.path; parsedWritePath = parsedPath.path;
const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel); const channelId = params.command.channelId ?? normalizeChannelId(params.command.channel);
const writeAuth = authorizeConfigWrite({ const deniedText = resolveConfigWriteDeniedText({
cfg: params.cfg, cfg: params.cfg,
origin: { channelId, accountId: params.ctx.AccountId },
target: resolveConfigWriteTargetFromPath(parsedWritePath),
allowBypass: canBypassConfigWritePolicy({
channel: params.command.channel, channel: params.command.channel,
channelId,
accountId: params.ctx.AccountId,
gatewayClientScopes: params.ctx.GatewayClientScopes, gatewayClientScopes: params.ctx.GatewayClientScopes,
}), target: resolveConfigWriteTargetFromPath(parsedWritePath),
}); });
if (!writeAuth.allowed) { if (deniedText) {
return { return {
shouldContinue: false, shouldContinue: false,
reply: { reply: {
text: formatConfigWriteDeniedMessage({ result: writeAuth, fallbackChannelId: channelId }), text: deniedText,
}, },
}; };
} }

View File

@ -0,0 +1,33 @@
import {
authorizeConfigWrite,
canBypassConfigWritePolicy,
formatConfigWriteDeniedMessage,
} from "../../channels/plugins/config-writes.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
export function resolveConfigWriteDeniedText(params: {
cfg: OpenClawConfig;
channel?: string | null;
channelId: ChannelId | null;
accountId?: string;
gatewayClientScopes?: string[];
target: Parameters<typeof authorizeConfigWrite>[0]["target"];
}): string | null {
const writeAuth = authorizeConfigWrite({
cfg: params.cfg,
origin: { channelId: params.channelId, accountId: params.accountId },
target: params.target,
allowBypass: canBypassConfigWritePolicy({
channel: params.channel ?? "",
gatewayClientScopes: params.gatewayClientScopes,
}),
});
if (writeAuth.allowed) {
return null;
}
return formatConfigWriteDeniedMessage({
result: writeAuth,
fallbackChannelId: params.channelId,
});
}

View File

@ -0,0 +1,15 @@
import { normalizeConversationText } from "../../acp/conversation-id.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
export function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeConversationText(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}

View File

@ -4,7 +4,7 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js"; import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js"; import type { FollowupRun } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js"; import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn(); const runEmbeddedPiAgentMock = vi.fn();
const routeReplyMock = vi.fn(); const routeReplyMock = vi.fn();
@ -50,47 +50,12 @@ beforeEach(() => {
}); });
const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
({ createMockFollowupRun({ run: { messageProvider } });
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
originatingTo: "channel:C1",
run: {
sessionId: "session",
sessionKey: "main",
messageProvider,
agentAccountId: "primary",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
}) as FollowupRun;
function createQueuedRun( function createQueuedRun(
overrides: Partial<Omit<FollowupRun, "run">> & { run?: Partial<FollowupRun["run"]> } = {}, overrides: Partial<Omit<FollowupRun, "run">> & { run?: Partial<FollowupRun["run"]> } = {},
): FollowupRun { ): FollowupRun {
const base = baseQueuedRun(); return createMockFollowupRun(overrides);
return {
...base,
...overrides,
run: {
...base.run,
...overrides.run,
},
};
} }
function mockCompactionRun(params: { function mockCompactionRun(params: {

View File

@ -34,11 +34,12 @@ import { resolveConversationIdFromTargets } from "../../infra/outbound/conversat
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js"; import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { normalizeMainKey, parseAgentSessionKey } from "../../routing/session-key.js"; import { normalizeMainKey } from "../../routing/session-key.js";
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js"; import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
import { resolveCommandAuthorization } from "../command-auth.js"; import { resolveCommandAuthorization } from "../command-auth.js";
import type { MsgContext, TemplateContext } from "../templating.js"; import type { MsgContext, TemplateContext } from "../templating.js";
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js"; import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
import { parseDiscordParentChannelFromSessionKey } from "./discord-parent-channel.js";
import { normalizeInboundTextNewlines } from "./inbound-text.js"; import { normalizeInboundTextNewlines } from "./inbound-text.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { import {
@ -70,19 +71,6 @@ export type SessionInitResult = {
triggerBodyNormalized: string; triggerBodyNormalized: string;
}; };
function parseDiscordParentChannelFromSessionKey(raw: unknown): string | undefined {
const sessionKey = normalizeConversationText(raw);
if (!sessionKey) {
return undefined;
}
const scoped = parseAgentSessionKey(sessionKey)?.rest ?? sessionKey.toLowerCase();
const match = scoped.match(/(?:^|:)channel:([^:]+)$/);
if (!match?.[1]) {
return undefined;
}
return match[1];
}
function resolveAcpResetBindingContext(ctx: MsgContext): { function resolveAcpResetBindingContext(ctx: MsgContext): {
channel: string; channel: string;
accountId: string; accountId: string;

View File

@ -1,4 +1,5 @@
import { vi } from "vitest"; import { vi } from "vitest";
import type { FollowupRun } from "./queue.js";
import type { TypingController } from "./typing.js"; import type { TypingController } from "./typing.js";
export function createMockTypingController( export function createMockTypingController(
@ -16,3 +17,44 @@ export function createMockTypingController(
...overrides, ...overrides,
}; };
} }
export function createMockFollowupRun(
overrides: Partial<Omit<FollowupRun, "run">> & { run?: Partial<FollowupRun["run"]> } = {},
): FollowupRun {
const base: FollowupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
originatingTo: "channel:C1",
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "whatsapp",
agentAccountId: "primary",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
};
return {
...base,
...overrides,
run: {
...base.run,
...overrides.run,
},
};
}