diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index ca0c56418bf..c9f99d1ee88 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -10,8 +10,8 @@ "exportName": "buildFalImageGenerationProvider", "kind": "function", "source": { - "line": 188, - "path": "src/image-generation/providers/fal.ts" + "line": 190, + "path": "extensions/fal/image-generation-provider.ts" } }, { @@ -19,8 +19,8 @@ "exportName": "buildGoogleImageGenerationProvider", "kind": "function", "source": { - "line": 96, - "path": "src/image-generation/providers/google.ts" + "line": 98, + "path": "extensions/google/image-generation-provider.ts" } }, { @@ -28,8 +28,8 @@ "exportName": "buildOpenAIImageGenerationProvider", "kind": "function", "source": { - "line": 42, - "path": "src/image-generation/providers/openai.ts" + "line": 43, + "path": "extensions/openai/image-generation-provider.ts" } }, { diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index d637408691e..4f638cfe75a 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -1,7 +1,7 @@ {"category":"legacy","entrypoint":"index","importSpecifier":"openclaw/plugin-sdk","recordType":"module","sourceLine":1,"sourcePath":"src/plugin-sdk/index.ts"} -{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":188,"sourcePath":"src/image-generation/providers/fal.ts"} -{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":96,"sourcePath":"src/image-generation/providers/google.ts"} -{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":42,"sourcePath":"src/image-generation/providers/openai.ts"} +{"declaration":"export function buildFalImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildFalImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":190,"sourcePath":"extensions/fal/image-generation-provider.ts"} +{"declaration":"export function buildGoogleImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildGoogleImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":98,"sourcePath":"extensions/google/image-generation-provider.ts"} +{"declaration":"export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider;","entrypoint":"index","exportName":"buildOpenAIImageGenerationProvider","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":43,"sourcePath":"extensions/openai/image-generation-provider.ts"} {"declaration":"export function delegateCompactionToRuntime(params: { sessionId: string; sessionKey?: string | undefined; sessionFile: string; tokenBudget?: number | undefined; force?: boolean | undefined; currentTokenCount?: number | undefined; compactionTarget?: \"budget\" | ... 1 more ... | undefined; customInstructions?: string | undefined; runtimeContext?: ContextEngineRuntimeContext | undefined; }): Promise<...>;","entrypoint":"index","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":16,"sourcePath":"src/context-engine/delegate.ts"} {"declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":13,"sourcePath":"src/plugins/config-schema.ts"} {"declaration":"export function onDiagnosticEvent(listener: (evt: DiagnosticEventPayload) => void): () => void;","entrypoint":"index","exportName":"onDiagnosticEvent","importSpecifier":"openclaw/plugin-sdk","kind":"function","recordType":"export","sourceLine":223,"sourcePath":"src/infra/diagnostic-events.ts"} diff --git a/extensions/googlechat/src/monitor-access.test.ts b/extensions/googlechat/src/monitor-access.test.ts new file mode 100644 index 00000000000..04db80809ac --- /dev/null +++ b/extensions/googlechat/src/monitor-access.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from "vitest"; + +const createChannelPairingController = vi.hoisted(() => vi.fn()); +const evaluateGroupRouteAccessForPolicy = vi.hoisted(() => vi.fn()); +const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn()); +const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn()); +const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn()); +const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn()); +const resolveMentionGatingWithBypass = vi.hoisted(() => vi.fn()); +const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn()); +const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn()); +const sendGoogleChatMessage = vi.hoisted(() => vi.fn()); + +vi.mock("../runtime-api.js", () => ({ + GROUP_POLICY_BLOCKED_LABEL: { space: "space" }, + createChannelPairingController, + evaluateGroupRouteAccessForPolicy, + isDangerousNameMatchingEnabled, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + resolveDmGroupAccessWithLists, + resolveMentionGatingWithBypass, + resolveSenderScopedGroupPolicy, + warnMissingProviderGroupPolicyFallbackOnce, +})); + +vi.mock("./api.js", () => ({ + sendGoogleChatMessage, +})); + +function createCore() { + return { + channel: { + commands: { + shouldComputeCommandAuthorized: vi.fn(() => false), + resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), + shouldHandleTextCommands: vi.fn(() => false), + isControlCommandMessage: vi.fn(() => false), + }, + text: { + hasControlCommand: vi.fn(() => false), + }, + }, + }; +} + +function primeCommonDefaults() { + isDangerousNameMatchingEnabled.mockReturnValue(false); + resolveDefaultGroupPolicy.mockReturnValue("allowlist"); + resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({ + groupPolicy: "allowlist", + providerMissingFallbackApplied: false, + }); + resolveSenderScopedGroupPolicy.mockImplementation(({ groupPolicy }) => groupPolicy); + evaluateGroupRouteAccessForPolicy.mockReturnValue({ + allowed: true, + }); + warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined); +} + +describe("googlechat inbound access policy", () => { + it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => { + primeCommonDefaults(); + const issueChallenge = vi.fn(async ({ onCreated, sendPairingReply }) => { + onCreated?.(); + await sendPairingReply("pairing text"); + }); + createChannelPairingController.mockReturnValue({ + readAllowFromStore: vi.fn(async () => []), + issueChallenge, + }); + resolveDmGroupAccessWithLists.mockReturnValue({ + decision: "pairing", + reason: "pairing_required", + effectiveAllowFrom: [], + effectiveGroupAllowFrom: [], + }); + sendGoogleChatMessage.mockResolvedValue({ ok: true }); + + const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"); + const statusSink = vi.fn(); + const logVerbose = vi.fn(); + + await expect( + applyGoogleChatInboundAccessPolicy({ + account: { + accountId: "default", + config: { + dm: { policy: "pairing" }, + }, + } as never, + config: { + channels: { googlechat: {} }, + } as never, + core: createCore() as never, + space: { name: "spaces/AAA", displayName: "DM" } as never, + message: { annotations: [] } as never, + isGroup: false, + senderId: "users/abc", + senderName: "Alice", + senderEmail: "alice@example.com", + rawBody: "hello", + statusSink, + logVerbose, + }), + ).resolves.toEqual({ ok: false }); + + expect(issueChallenge).toHaveBeenCalledTimes(1); + expect(sendGoogleChatMessage).toHaveBeenCalledWith({ + account: expect.anything(), + space: "spaces/AAA", + text: "pairing text", + }); + expect(statusSink).toHaveBeenCalledWith( + expect.objectContaining({ + lastOutboundAt: expect.any(Number), + }), + ); + }); + + it("allows group traffic when sender and mention gates pass", async () => { + primeCommonDefaults(); + createChannelPairingController.mockReturnValue({ + readAllowFromStore: vi.fn(async () => []), + issueChallenge: vi.fn(), + }); + resolveDmGroupAccessWithLists.mockReturnValue({ + decision: "allow", + effectiveAllowFrom: [], + effectiveGroupAllowFrom: ["users/alice"], + }); + resolveMentionGatingWithBypass.mockReturnValue({ + shouldSkip: false, + effectiveWasMentioned: true, + }); + const core = createCore(); + core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true); + core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); + + const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"); + + await expect( + applyGoogleChatInboundAccessPolicy({ + account: { + accountId: "default", + config: { + botUser: "users/app-bot", + groups: { + "spaces/AAA": { + users: ["users/alice"], + requireMention: true, + systemPrompt: " group prompt ", + }, + }, + }, + } as never, + config: { + channels: { googlechat: {} }, + commands: { useAccessGroups: true }, + } as never, + core: core as never, + space: { name: "spaces/AAA", displayName: "Team Room" } as never, + message: { + annotations: [ + { + type: "USER_MENTION", + userMention: { user: { name: "users/app-bot" } }, + }, + ], + } as never, + isGroup: true, + senderId: "users/alice", + senderName: "Alice", + senderEmail: "alice@example.com", + rawBody: "hello team", + logVerbose: vi.fn(), + }), + ).resolves.toEqual({ + ok: true, + commandAuthorized: true, + effectiveWasMentioned: true, + groupSystemPrompt: "group prompt", + }); + }); + + it("drops unauthorized group control commands", async () => { + primeCommonDefaults(); + createChannelPairingController.mockReturnValue({ + readAllowFromStore: vi.fn(async () => []), + issueChallenge: vi.fn(), + }); + resolveDmGroupAccessWithLists.mockReturnValue({ + decision: "allow", + effectiveAllowFrom: [], + effectiveGroupAllowFrom: [], + }); + resolveMentionGatingWithBypass.mockReturnValue({ + shouldSkip: false, + effectiveWasMentioned: false, + }); + const core = createCore(); + core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true); + core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); + core.channel.commands.isControlCommandMessage.mockReturnValue(true); + const logVerbose = vi.fn(); + + const { applyGoogleChatInboundAccessPolicy } = await import("./monitor-access.js"); + + await expect( + applyGoogleChatInboundAccessPolicy({ + account: { + accountId: "default", + config: {}, + } as never, + config: { + channels: { googlechat: {} }, + commands: { useAccessGroups: true }, + } as never, + core: core as never, + space: { name: "spaces/AAA", displayName: "Team Room" } as never, + message: { annotations: [] } as never, + isGroup: true, + senderId: "users/alice", + senderName: "Alice", + senderEmail: "alice@example.com", + rawBody: "/admin", + logVerbose, + }), + ).resolves.toEqual({ ok: false }); + + expect(logVerbose).toHaveBeenCalledWith("googlechat: drop control command from users/alice"); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-auth.test.ts b/extensions/mattermost/src/mattermost/monitor-auth.test.ts new file mode 100644 index 00000000000..45f857e9021 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-auth.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; + +const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn()); +const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn()); +const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn()); +const resolveControlCommandGate = vi.hoisted(() => vi.fn()); +const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn()); + +vi.mock("../runtime-api.js", () => ({ + evaluateSenderGroupAccessForPolicy, + isDangerousNameMatchingEnabled, + resolveAllowlistMatchSimple, + resolveControlCommandGate, + resolveEffectiveAllowFromLists, +})); + +describe("mattermost monitor auth", () => { + it("normalizes allowlist entries and resolves effective lists", async () => { + resolveEffectiveAllowFromLists.mockReturnValue({ + effectiveAllowFrom: ["alice"], + effectiveGroupAllowFrom: ["team"], + }); + + const { + normalizeMattermostAllowEntry, + normalizeMattermostAllowList, + resolveMattermostEffectiveAllowFromLists, + } = await import("./monitor-auth.js"); + + expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice"); + expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob"); + expect(normalizeMattermostAllowEntry("*")).toBe("*"); + expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([ + "alice", + "*", + ]); + expect( + resolveMattermostEffectiveAllowFromLists({ + allowFrom: [" Alice "], + groupAllowFrom: [" Team "], + storeAllowFrom: ["Store"], + dmPolicy: "pairing", + }), + ).toEqual({ + effectiveAllowFrom: ["alice"], + effectiveGroupAllowFrom: ["team"], + }); + expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({ + allowFrom: ["alice"], + groupAllowFrom: ["team"], + storeAllowFrom: ["store"], + dmPolicy: "pairing", + }); + }); + + it("checks sender allowlists against normalized ids and names", async () => { + resolveAllowlistMatchSimple.mockReturnValue({ allowed: true }); + + const { isMattermostSenderAllowed } = await import("./monitor-auth.js"); + expect( + isMattermostSenderAllowed({ + senderId: "@Alice", + senderName: "Alice", + allowFrom: [" mattermost:alice "], + allowNameMatching: true, + }), + ).toBe(true); + expect(resolveAllowlistMatchSimple).toHaveBeenCalledWith({ + allowFrom: ["alice"], + senderId: "alice", + senderName: "alice", + allowNameMatching: true, + }); + }); + + it("authorizes direct messages in open mode and blocks disabled/group-restricted channels", async () => { + isDangerousNameMatchingEnabled.mockReturnValue(false); + resolveEffectiveAllowFromLists.mockReturnValue({ + effectiveAllowFrom: [], + effectiveGroupAllowFrom: [], + }); + resolveControlCommandGate.mockReturnValue({ + commandAuthorized: false, + shouldBlock: false, + }); + evaluateSenderGroupAccessForPolicy.mockReturnValue({ + allowed: false, + reason: "empty_allowlist", + }); + resolveAllowlistMatchSimple.mockReturnValue({ allowed: false }); + + const { authorizeMattermostCommandInvocation } = await import("./monitor-auth.js"); + + expect( + authorizeMattermostCommandInvocation({ + account: { + config: { dmPolicy: "open" }, + } as never, + cfg: {} as never, + senderId: "alice", + senderName: "Alice", + channelId: "dm-1", + channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never, + allowTextCommands: false, + hasControlCommand: false, + }), + ).toMatchObject({ + ok: true, + commandAuthorized: true, + kind: "direct", + roomLabel: "#alice", + }); + + expect( + authorizeMattermostCommandInvocation({ + account: { + config: { dmPolicy: "disabled" }, + } as never, + cfg: {} as never, + senderId: "alice", + senderName: "Alice", + channelId: "dm-1", + channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never, + allowTextCommands: false, + hasControlCommand: false, + }), + ).toMatchObject({ + ok: false, + denyReason: "dm-disabled", + }); + + expect( + authorizeMattermostCommandInvocation({ + account: { + config: { groupPolicy: "allowlist" }, + } as never, + cfg: {} as never, + senderId: "alice", + senderName: "Alice", + channelId: "chan-1", + channelInfo: { type: "O", name: "town-square", display_name: "Town Square" } as never, + allowTextCommands: true, + hasControlCommand: false, + }), + ).toMatchObject({ + ok: false, + denyReason: "channel-no-allowlist", + kind: "channel", + }); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor-gating.test.ts b/extensions/mattermost/src/mattermost/monitor-gating.test.ts new file mode 100644 index 00000000000..e818467c626 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-gating.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { + evaluateMattermostMentionGate, + mapMattermostChannelTypeToChatType, +} from "./monitor-gating.js"; + +describe("mattermost monitor gating", () => { + it("maps mattermost channel types to chat types", () => { + expect(mapMattermostChannelTypeToChatType("D")).toBe("direct"); + expect(mapMattermostChannelTypeToChatType("G")).toBe("group"); + expect(mapMattermostChannelTypeToChatType("P")).toBe("group"); + expect(mapMattermostChannelTypeToChatType("O")).toBe("channel"); + expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel"); + }); + + it("drops non-mentioned traffic when onchar is enabled but not triggered", () => { + const resolveRequireMention = vi.fn(() => true); + + expect( + evaluateMattermostMentionGate({ + kind: "channel", + cfg: {} as never, + accountId: "default", + channelId: "chan-1", + resolveRequireMention, + wasMentioned: false, + isControlCommand: false, + commandAuthorized: false, + oncharEnabled: true, + oncharTriggered: false, + canDetectMention: true, + }), + ).toEqual({ + shouldRequireMention: true, + shouldBypassMention: false, + effectiveWasMentioned: false, + dropReason: "onchar-not-triggered", + }); + }); + + it("bypasses mention for authorized control commands and allows direct chats", () => { + const resolveRequireMention = vi.fn(() => true); + + expect( + evaluateMattermostMentionGate({ + kind: "channel", + cfg: {} as never, + accountId: "default", + channelId: "chan-1", + resolveRequireMention, + wasMentioned: false, + isControlCommand: true, + commandAuthorized: true, + oncharEnabled: false, + oncharTriggered: false, + canDetectMention: true, + }), + ).toEqual({ + shouldRequireMention: true, + shouldBypassMention: true, + effectiveWasMentioned: true, + dropReason: null, + }); + + expect( + evaluateMattermostMentionGate({ + kind: "direct", + cfg: {} as never, + accountId: "default", + channelId: "chan-1", + resolveRequireMention, + wasMentioned: false, + isControlCommand: false, + commandAuthorized: false, + oncharEnabled: false, + oncharTriggered: false, + canDetectMention: true, + }), + ).toMatchObject({ + shouldRequireMention: false, + dropReason: null, + }); + }); +}); diff --git a/extensions/nextcloud-talk/src/setup-core.test.ts b/extensions/nextcloud-talk/src/setup-core.test.ts index becb95c50dd..3d70a9340da 100644 --- a/extensions/nextcloud-talk/src/setup-core.test.ts +++ b/extensions/nextcloud-talk/src/setup-core.test.ts @@ -53,7 +53,9 @@ describe("nextcloud talk setup core", () => { }, }); - expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toMatchObject({ + expect( + clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]), + ).toMatchObject({ channels: { "nextcloud-talk": { baseUrl: "https://cloud.example.com", @@ -70,19 +72,18 @@ describe("nextcloud talk setup core", () => { }, }); - expect( - clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"]), - ).toMatchObject({ - channels: { - "nextcloud-talk": { - accounts: { - work: { - apiPassword: "api-secret", + expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"])) + .toMatchObject({ + channels: { + "nextcloud-talk": { + accounts: { + work: { + apiPassword: "api-secret", + }, }, }, }, - }, - }); + }); }); it("sets top-level DM policy state", async () => { diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index 51485252fc9..302ce168536 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,6 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; import { buildOpenAISpeechProvider } from "./speech-provider.js"; diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 5f4eeab2694..d05d628d0ef 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -1,4 +1,5 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; +import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import type { ReplyToMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { stripHeartbeatToken } from "../heartbeat.js"; @@ -13,13 +14,16 @@ import { resolveOriginMessageTo, } from "./origin-routing.js"; import { normalizeReplyPayloadDirectives } from "./reply-delivery.js"; -import { - applyReplyThreading, - filterMessagingToolDuplicates, - filterMessagingToolMediaDuplicates, - isRenderablePayload, - shouldSuppressMessagingToolReplies, -} from "./reply-payloads.js"; +import { applyReplyThreading, isRenderablePayload } from "./reply-payloads-base.js"; + +let replyPayloadsDedupeRuntimePromise: Promise< + typeof import("./reply-payloads-dedupe.runtime.js") +> | null = null; + +function loadReplyPayloadsDedupeRuntime() { + replyPayloadsDedupeRuntimePromise ??= import("./reply-payloads-dedupe.runtime.js"); + return replyPayloadsDedupeRuntimePromise; +} async function normalizeReplyPayloadMedia(params: { payload: ReplyPayload; @@ -97,9 +101,7 @@ export async function buildReplyPayloads(params: { messageProvider?: string; messagingToolSentTexts?: string[]; messagingToolSentMediaUrls?: string[]; - messagingToolSentTargets?: Parameters< - typeof shouldSuppressMessagingToolReplies - >[0]["messagingToolSentTargets"]; + messagingToolSentTargets?: MessagingToolSend[]; originatingChannel?: OriginatingChannelType; originatingTo?: string; accountId?: string; @@ -160,19 +162,27 @@ export async function buildReplyPayloads(params: { !params.blockReplyPipeline?.isAborted(); const messagingToolSentTexts = params.messagingToolSentTexts ?? []; const messagingToolSentTargets = params.messagingToolSentTargets ?? []; - const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({ - messageProvider: resolveOriginMessageProvider({ - originatingChannel: params.originatingChannel, - provider: params.messageProvider, - }), - messagingToolSentTargets, - originatingTo: resolveOriginMessageTo({ - originatingTo: params.originatingTo, - }), - accountId: resolveOriginAccountId({ - originatingAccountId: params.accountId, - }), - }); + const shouldCheckMessagingToolDedupe = + messagingToolSentTexts.length > 0 || + (params.messagingToolSentMediaUrls?.length ?? 0) > 0 || + messagingToolSentTargets.length > 0; + const dedupeRuntime = shouldCheckMessagingToolDedupe + ? await loadReplyPayloadsDedupeRuntime() + : null; + const suppressMessagingToolReplies = + dedupeRuntime?.shouldSuppressMessagingToolReplies({ + messageProvider: resolveOriginMessageProvider({ + originatingChannel: params.originatingChannel, + provider: params.messageProvider, + }), + messagingToolSentTargets, + originatingTo: resolveOriginMessageTo({ + originatingTo: params.originatingTo, + }), + accountId: resolveOriginAccountId({ + originatingAccountId: params.accountId, + }), + }) ?? false; // Only dedupe against messaging tool sends for the same origin target. // Cross-target sends (for example posting to another channel) must not // suppress the current conversation's final reply. @@ -186,13 +196,15 @@ export async function buildReplyPayloads(params: { }) : (params.messagingToolSentMediaUrls ?? []); const dedupedPayloads = dedupeMessagingToolPayloads - ? filterMessagingToolDuplicates({ + ? (dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime())).filterMessagingToolDuplicates({ payloads: replyTaggedPayloads, sentTexts: messagingToolSentTexts, }) : replyTaggedPayloads; const mediaFilteredPayloads = dedupeMessagingToolPayloads - ? filterMessagingToolMediaDuplicates({ + ? ( + dedupeRuntime ?? (await loadReplyPayloadsDedupeRuntime()) + ).filterMessagingToolMediaDuplicates({ payloads: dedupedPayloads, sentMediaUrls: messagingToolSentMediaUrls, }) diff --git a/src/auto-reply/reply/reply-payloads-base.ts b/src/auto-reply/reply/reply-payloads-base.ts new file mode 100644 index 00000000000..b68e44fad1a --- /dev/null +++ b/src/auto-reply/reply/reply-payloads-base.ts @@ -0,0 +1,88 @@ +import type { ReplyToMode } from "../../config/types.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; +import type { OriginatingChannelType } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; +import { extractReplyToTag } from "./reply-tags.js"; +import { createReplyToModeFilterForChannel } from "./reply-threading.js"; + +export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined { + const text = payload.text?.trim(); + if (!text) { + return payload.text; + } + const question = payload.btw?.question?.trim(); + if (!question) { + return payload.text; + } + const formatted = `BTW\nQuestion: ${question}\n\n${text}`; + return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted; +} + +function resolveReplyThreadingForPayload(params: { + payload: ReplyPayload; + implicitReplyToId?: string; + currentMessageId?: string; +}): ReplyPayload { + const implicitReplyToId = params.implicitReplyToId?.trim() || undefined; + const currentMessageId = params.currentMessageId?.trim() || undefined; + + let resolved: ReplyPayload = + params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId + ? params.payload + : { ...params.payload, replyToId: implicitReplyToId }; + + if (typeof resolved.text === "string" && resolved.text.includes("[[")) { + const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag( + resolved.text, + currentMessageId, + ); + resolved = { + ...resolved, + text: cleaned ? cleaned : undefined, + replyToId: replyToId ?? resolved.replyToId, + replyToTag: hasTag || resolved.replyToTag, + replyToCurrent: replyToCurrent || resolved.replyToCurrent, + }; + } + + if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) { + resolved = { + ...resolved, + replyToId: currentMessageId, + }; + } + + return resolved; +} + +export function applyReplyTagsToPayload( + payload: ReplyPayload, + currentMessageId?: string, +): ReplyPayload { + return resolveReplyThreadingForPayload({ payload, currentMessageId }); +} + +export function isRenderablePayload(payload: ReplyPayload): boolean { + return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice }); +} + +export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { + return payload.isReasoning === true; +} + +export function applyReplyThreading(params: { + payloads: ReplyPayload[]; + replyToMode: ReplyToMode; + replyToChannel?: OriginatingChannelType; + currentMessageId?: string; +}): ReplyPayload[] { + const { payloads, replyToMode, replyToChannel, currentMessageId } = params; + const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); + const implicitReplyToId = currentMessageId?.trim() || undefined; + return payloads + .map((payload) => + resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }), + ) + .filter(isRenderablePayload) + .map(applyReplyToMode); +} diff --git a/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts b/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts new file mode 100644 index 00000000000..48fc5a88eff --- /dev/null +++ b/src/auto-reply/reply/reply-payloads-dedupe.runtime.ts @@ -0,0 +1,5 @@ +export { + filterMessagingToolDuplicates, + filterMessagingToolMediaDuplicates, + shouldSuppressMessagingToolReplies, +} from "./reply-payloads-dedupe.js"; diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts new file mode 100644 index 00000000000..8a757c6a8a4 --- /dev/null +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -0,0 +1,179 @@ +import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; +import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js"; +import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; +import { normalizeOptionalAccountId } from "../../routing/account-id.js"; +import type { ReplyPayload } from "../types.js"; + +export function filterMessagingToolDuplicates(params: { + payloads: ReplyPayload[]; + sentTexts: string[]; +}): ReplyPayload[] { + const { payloads, sentTexts } = params; + if (sentTexts.length === 0) { + return payloads; + } + return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts)); +} + +export function filterMessagingToolMediaDuplicates(params: { + payloads: ReplyPayload[]; + sentMediaUrls: string[]; +}): ReplyPayload[] { + const normalizeMediaForDedupe = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (!trimmed.toLowerCase().startsWith("file://")) { + return trimmed; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol === "file:") { + return decodeURIComponent(parsed.pathname || ""); + } + } catch { + // Keep fallback below for non-URL-like inputs. + } + return trimmed.replace(/^file:\/\//i, ""); + }; + + const { payloads, sentMediaUrls } = params; + if (sentMediaUrls.length === 0) { + return payloads; + } + const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean)); + return payloads.map((payload) => { + const mediaUrl = payload.mediaUrl; + const mediaUrls = payload.mediaUrls; + const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl)); + const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u))); + if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) { + return payload; + } + return { + ...payload, + mediaUrl: stripSingle ? undefined : mediaUrl, + mediaUrls: filteredUrls?.length ? filteredUrls : undefined, + }; + }); +} + +const PROVIDER_ALIAS_MAP: Record = { + lark: "feishu", +}; + +function normalizeProviderForComparison(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const lowered = trimmed.toLowerCase(); + const normalizedChannel = normalizeChannelId(trimmed); + if (normalizedChannel) { + return normalizedChannel; + } + return PROVIDER_ALIAS_MAP[lowered] ?? lowered; +} + +function normalizeThreadIdForComparison(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (/^-?\d+$/.test(trimmed)) { + return String(Number.parseInt(trimmed, 10)); + } + return trimmed.toLowerCase(); +} + +function resolveTargetProviderForComparison(params: { + currentProvider: string; + targetProvider?: string; +}): string { + const targetProvider = normalizeProviderForComparison(params.targetProvider); + if (!targetProvider || targetProvider === "message") { + return params.currentProvider; + } + return targetProvider; +} + +function targetsMatchForSuppression(params: { + provider: string; + originTarget: string; + targetKey: string; + targetThreadId?: string; +}): boolean { + if (params.provider !== "telegram") { + return params.targetKey === params.originTarget; + } + + const origin = parseExplicitTargetForChannel("telegram", params.originTarget); + const target = parseExplicitTargetForChannel("telegram", params.targetKey); + if (!origin || !target) { + return params.targetKey === params.originTarget; + } + const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId); + const targetThreadId = + explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined); + const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined; + if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) { + return false; + } + if (originThreadId && targetThreadId != null) { + return originThreadId === targetThreadId; + } + if (originThreadId && targetThreadId == null) { + return false; + } + if (!originThreadId && targetThreadId != null) { + return false; + } + return true; +} + +export function shouldSuppressMessagingToolReplies(params: { + messageProvider?: string; + messagingToolSentTargets?: MessagingToolSend[]; + originatingTo?: string; + accountId?: string; +}): boolean { + const provider = normalizeProviderForComparison(params.messageProvider); + if (!provider) { + return false; + } + const originTarget = normalizeTargetForProvider(provider, params.originatingTo); + if (!originTarget) { + return false; + } + const originAccount = normalizeOptionalAccountId(params.accountId); + const sentTargets = params.messagingToolSentTargets ?? []; + if (sentTargets.length === 0) { + return false; + } + return sentTargets.some((target) => { + const targetProvider = resolveTargetProviderForComparison({ + currentProvider: provider, + targetProvider: target?.provider, + }); + if (targetProvider !== provider) { + return false; + } + const targetKey = normalizeTargetForProvider(targetProvider, target.to); + if (!targetKey) { + return false; + } + const targetAccount = normalizeOptionalAccountId(target.accountId); + if (originAccount && targetAccount && originAccount !== targetAccount) { + return false; + } + return targetsMatchForSuppression({ + provider, + originTarget, + targetKey, + targetThreadId: target.threadId, + }); + }); +} diff --git a/src/auto-reply/reply/reply-payloads.runtime.ts b/src/auto-reply/reply/reply-payloads.runtime.ts index 5c42f5124da..fa613df5574 100644 --- a/src/auto-reply/reply/reply-payloads.runtime.ts +++ b/src/auto-reply/reply/reply-payloads.runtime.ts @@ -1,6 +1,6 @@ +export { applyReplyThreading } from "./reply-payloads-base.js"; export { - applyReplyThreading, filterMessagingToolDuplicates, filterMessagingToolMediaDuplicates, shouldSuppressMessagingToolReplies, -} from "./reply-payloads.js"; +} from "./reply-payloads-dedupe.js"; diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 1826d1872af..13f732ae188 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -1,273 +1,12 @@ -import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; -import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; -import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js"; -import type { ReplyToMode } from "../../config/types.js"; -import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; -import { hasReplyPayloadContent } from "../../interactive/payload.js"; -import { normalizeOptionalAccountId } from "../../routing/account-id.js"; -import type { OriginatingChannelType } from "../templating.js"; -import type { ReplyPayload } from "../types.js"; -import { extractReplyToTag } from "./reply-tags.js"; -import { createReplyToModeFilterForChannel } from "./reply-threading.js"; - -export function formatBtwTextForExternalDelivery(payload: ReplyPayload): string | undefined { - const text = payload.text?.trim(); - if (!text) { - return payload.text; - } - const question = payload.btw?.question?.trim(); - if (!question) { - return payload.text; - } - const formatted = `BTW\nQuestion: ${question}\n\n${text}`; - return text === formatted || text.startsWith("BTW\nQuestion:") ? text : formatted; -} - -function resolveReplyThreadingForPayload(params: { - payload: ReplyPayload; - implicitReplyToId?: string; - currentMessageId?: string; -}): ReplyPayload { - const implicitReplyToId = params.implicitReplyToId?.trim() || undefined; - const currentMessageId = params.currentMessageId?.trim() || undefined; - - // 1) Apply implicit reply threading first (replyToMode will strip later if needed). - let resolved: ReplyPayload = - params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId - ? params.payload - : { ...params.payload, replyToId: implicitReplyToId }; - - // 2) Parse explicit reply tags from text (if present) and clean them. - if (typeof resolved.text === "string" && resolved.text.includes("[[")) { - const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag( - resolved.text, - currentMessageId, - ); - resolved = { - ...resolved, - text: cleaned ? cleaned : undefined, - replyToId: replyToId ?? resolved.replyToId, - replyToTag: hasTag || resolved.replyToTag, - replyToCurrent: replyToCurrent || resolved.replyToCurrent, - }; - } - - // 3) If replyToCurrent was set out-of-band (e.g. tags already stripped upstream), - // ensure replyToId is set to the current message id when available. - if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) { - resolved = { - ...resolved, - replyToId: currentMessageId, - }; - } - - return resolved; -} - -// Backward-compatible helper: apply explicit reply tags/directives to a single payload. -// This intentionally does not apply implicit threading. -export function applyReplyTagsToPayload( - payload: ReplyPayload, - currentMessageId?: string, -): ReplyPayload { - return resolveReplyThreadingForPayload({ payload, currentMessageId }); -} - -export function isRenderablePayload(payload: ReplyPayload): boolean { - return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice }); -} - -export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { - return payload.isReasoning === true; -} - -export function applyReplyThreading(params: { - payloads: ReplyPayload[]; - replyToMode: ReplyToMode; - replyToChannel?: OriginatingChannelType; - currentMessageId?: string; -}): ReplyPayload[] { - const { payloads, replyToMode, replyToChannel, currentMessageId } = params; - const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); - const implicitReplyToId = currentMessageId?.trim() || undefined; - return payloads - .map((payload) => - resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }), - ) - .filter(isRenderablePayload) - .map(applyReplyToMode); -} - -export function filterMessagingToolDuplicates(params: { - payloads: ReplyPayload[]; - sentTexts: string[]; -}): ReplyPayload[] { - const { payloads, sentTexts } = params; - if (sentTexts.length === 0) { - return payloads; - } - return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts)); -} - -export function filterMessagingToolMediaDuplicates(params: { - payloads: ReplyPayload[]; - sentMediaUrls: string[]; -}): ReplyPayload[] { - const normalizeMediaForDedupe = (value: string): string => { - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - if (!trimmed.toLowerCase().startsWith("file://")) { - return trimmed; - } - try { - const parsed = new URL(trimmed); - if (parsed.protocol === "file:") { - return decodeURIComponent(parsed.pathname || ""); - } - } catch { - // Keep fallback below for non-URL-like inputs. - } - return trimmed.replace(/^file:\/\//i, ""); - }; - - const { payloads, sentMediaUrls } = params; - if (sentMediaUrls.length === 0) { - return payloads; - } - const sentSet = new Set(sentMediaUrls.map(normalizeMediaForDedupe).filter(Boolean)); - return payloads.map((payload) => { - const mediaUrl = payload.mediaUrl; - const mediaUrls = payload.mediaUrls; - const stripSingle = mediaUrl && sentSet.has(normalizeMediaForDedupe(mediaUrl)); - const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(normalizeMediaForDedupe(u))); - if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) { - return payload; // No change - } - return { - ...payload, - mediaUrl: stripSingle ? undefined : mediaUrl, - mediaUrls: filteredUrls?.length ? filteredUrls : undefined, - }; - }); -} - -const PROVIDER_ALIAS_MAP: Record = { - lark: "feishu", -}; - -function normalizeProviderForComparison(value?: string): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - const lowered = trimmed.toLowerCase(); - const normalizedChannel = normalizeChannelId(trimmed); - if (normalizedChannel) { - return normalizedChannel; - } - return PROVIDER_ALIAS_MAP[lowered] ?? lowered; -} - -function normalizeThreadIdForComparison(value?: string): string | undefined { - const trimmed = value?.trim(); - if (!trimmed) { - return undefined; - } - if (/^-?\d+$/.test(trimmed)) { - return String(Number.parseInt(trimmed, 10)); - } - return trimmed.toLowerCase(); -} - -function resolveTargetProviderForComparison(params: { - currentProvider: string; - targetProvider?: string; -}): string { - const targetProvider = normalizeProviderForComparison(params.targetProvider); - if (!targetProvider || targetProvider === "message") { - return params.currentProvider; - } - return targetProvider; -} - -function targetsMatchForSuppression(params: { - provider: string; - originTarget: string; - targetKey: string; - targetThreadId?: string; -}): boolean { - if (params.provider !== "telegram") { - return params.targetKey === params.originTarget; - } - - const origin = parseExplicitTargetForChannel("telegram", params.originTarget); - const target = parseExplicitTargetForChannel("telegram", params.targetKey); - if (!origin || !target) { - return params.targetKey === params.originTarget; - } - const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId); - const targetThreadId = - explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined); - const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined; - if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) { - return false; - } - if (originThreadId && targetThreadId != null) { - return originThreadId === targetThreadId; - } - if (originThreadId && targetThreadId == null) { - return false; - } - if (!originThreadId && targetThreadId != null) { - return false; - } - // chatId already matched and neither side carries thread context. - return true; -} - -export function shouldSuppressMessagingToolReplies(params: { - messageProvider?: string; - messagingToolSentTargets?: MessagingToolSend[]; - originatingTo?: string; - accountId?: string; -}): boolean { - const provider = normalizeProviderForComparison(params.messageProvider); - if (!provider) { - return false; - } - const originTarget = normalizeTargetForProvider(provider, params.originatingTo); - if (!originTarget) { - return false; - } - const originAccount = normalizeOptionalAccountId(params.accountId); - const sentTargets = params.messagingToolSentTargets ?? []; - if (sentTargets.length === 0) { - return false; - } - return sentTargets.some((target) => { - const targetProvider = resolveTargetProviderForComparison({ - currentProvider: provider, - targetProvider: target?.provider, - }); - if (targetProvider !== provider) { - return false; - } - const targetKey = normalizeTargetForProvider(targetProvider, target.to); - if (!targetKey) { - return false; - } - const targetAccount = normalizeOptionalAccountId(target.accountId); - if (originAccount && targetAccount && originAccount !== targetAccount) { - return false; - } - return targetsMatchForSuppression({ - provider, - originTarget, - targetKey, - targetThreadId: target.threadId, - }); - }); -} +export { + applyReplyTagsToPayload, + applyReplyThreading, + formatBtwTextForExternalDelivery, + isRenderablePayload, + shouldSuppressReasoningPayload, +} from "./reply-payloads-base.js"; +export { + filterMessagingToolDuplicates, + filterMessagingToolMediaDuplicates, + shouldSuppressMessagingToolReplies, +} from "./reply-payloads-dedupe.js"; diff --git a/src/image-generation/providers/fal.test.ts b/src/image-generation/providers/fal.test.ts index 2e276616ab3..e60420a0343 100644 --- a/src/image-generation/providers/fal.test.ts +++ b/src/image-generation/providers/fal.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildFalImageGenerationProvider } from "../../../extensions/fal/image-generation-provider.js"; import * as modelAuth from "../../agents/model-auth.js"; +import { buildFalImageGenerationProvider } from "../../../extensions/fal/image-generation-provider.js"; function expectFalJsonPost( fetchMock: ReturnType, diff --git a/src/image-generation/providers/google.test.ts b/src/image-generation/providers/google.test.ts index 9bc053d51b9..7a6b35b1316 100644 --- a/src/image-generation/providers/google.test.ts +++ b/src/image-generation/providers/google.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildGoogleImageGenerationProvider } from "../../../extensions/google/image-generation-provider.js"; import * as modelAuth from "../../agents/model-auth.js"; +import { buildGoogleImageGenerationProvider } from "../../../extensions/google/image-generation-provider.js"; describe("Google image-generation provider", () => { afterEach(() => { diff --git a/src/image-generation/providers/openai.test.ts b/src/image-generation/providers/openai.test.ts index f49f3329a4c..67d49ce3e30 100644 --- a/src/image-generation/providers/openai.test.ts +++ b/src/image-generation/providers/openai.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildOpenAIImageGenerationProvider } from "../../../extensions/openai/image-generation-provider.js"; import * as modelAuth from "../../agents/model-auth.js"; +import { buildOpenAIImageGenerationProvider } from "../../../extensions/openai/image-generation-provider.js"; describe("OpenAI image-generation provider", () => { afterEach(() => { diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index 9e67d3cd063..22e226d451b 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -165,10 +165,8 @@ describe("applyMediaUnderstanding – echo transcript", () => { })); vi.doMock("./providers/index.js", async (importOriginal) => { const actual = await importOriginal(); - const { deepgramProvider } = - await import("../../extensions/deepgram/media-understanding-provider.js"); - const { groqProvider } = - await import("../../extensions/groq/media-understanding-provider.js"); + const { deepgramProvider } = await import("../../extensions/deepgram/media-understanding-provider.js"); + const { groqProvider } = await import("../../extensions/groq/media-understanding-provider.js"); return { ...actual, buildMediaUnderstandingRegistry: ( diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 286304f13bd..966f7b720ae 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -248,10 +248,8 @@ describe("applyMediaUnderstanding", () => { })); vi.doMock("./providers/index.js", async (importOriginal) => { const actual = await importOriginal(); - const { deepgramProvider } = - await import("../../extensions/deepgram/media-understanding-provider.js"); - const { groqProvider } = - await import("../../extensions/groq/media-understanding-provider.js"); + const { deepgramProvider } = await import("../../extensions/deepgram/media-understanding-provider.js"); + const { groqProvider } = await import("../../extensions/groq/media-understanding-provider.js"); return { ...actual, buildMediaUnderstandingRegistry: ( diff --git a/src/media-understanding/providers/deepgram/audio.live.test.ts b/src/media-understanding/providers/deepgram/audio.live.test.ts index 3a4dce69a52..dddb8e8c9c5 100644 --- a/src/media-understanding/providers/deepgram/audio.live.test.ts +++ b/src/media-understanding/providers/deepgram/audio.live.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js"; import { isTruthyEnvValue } from "../../../infra/env.js"; +import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js"; const DEEPGRAM_KEY = process.env.DEEPGRAM_API_KEY ?? ""; const DEEPGRAM_MODEL = process.env.DEEPGRAM_MODEL?.trim() || "nova-3"; diff --git a/src/media-understanding/providers/deepgram/audio.test.ts b/src/media-understanding/providers/deepgram/audio.test.ts index f1f0b441515..5b57011dc23 100644 --- a/src/media-understanding/providers/deepgram/audio.test.ts +++ b/src/media-understanding/providers/deepgram/audio.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js"; import { createAuthCaptureJsonFetch, createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; +import { transcribeDeepgramAudio } from "../../../../extensions/deepgram/media-understanding-provider.js"; installPinnedHostnameTestHooks(); diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index 66aad6f98df..732c282b3ff 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -468,6 +468,28 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ ], }, }, + { + dirName: "deepgram", + idHint: "deepgram-media-understanding", + source: { + source: "./index.ts", + built: "index.js", + }, + packageName: "@openclaw/deepgram-media-understanding", + packageVersion: "2026.3.14", + packageDescription: "OpenClaw Deepgram media-understanding plugin", + packageManifest: { + extensions: ["./index.ts"], + }, + manifest: { + id: "deepgram", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + }, { dirName: "diagnostics-otel", idHint: "diagnostics-otel", @@ -1049,6 +1071,28 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ channels: ["googlechat"], }, }, + { + dirName: "groq", + idHint: "groq-media-understanding", + source: { + source: "./index.ts", + built: "index.js", + }, + packageName: "@openclaw/groq-media-understanding", + packageVersion: "2026.3.14", + packageDescription: "OpenClaw Groq media-understanding plugin", + packageManifest: { + extensions: ["./index.ts"], + }, + manifest: { + id: "groq", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + }, { dirName: "huggingface", idHint: "huggingface", diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index 4f70139a013..bc481628b64 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -23,50 +23,6 @@ } ], "threadPinned": [ - { - "file": "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts", - "reason": "Measured ~15% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts", - "reason": "Measured ~13% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/cron/isolated-agent/run.cron-model-override.test.ts", - "reason": "Measured ~25% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/cron/isolated-agent/run.owner-auth.test.ts", - "reason": "Measured ~19% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/cron/isolated-agent.auth-profile-propagation.test.ts", - "reason": "Measured ~22% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts", - "reason": "Measured ~14% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/cron/isolated-agent.subagent-model.test.ts", - "reason": "Measured ~21% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/infra/outbound/deliver.test.ts", - "reason": "Terminates cleanly under threads, but not process forks on this host." - }, - { - "file": "src/infra/outbound/deliver.lifecycle.test.ts", - "reason": "Terminates cleanly under threads, but not process forks on this host." - }, - { - "file": "src/infra/outbound/message.channels.test.ts", - "reason": "Terminates cleanly under threads, but not process forks on this host." - }, - { - "file": "src/infra/outbound/message-action-runner.poll.test.ts", - "reason": "Terminates cleanly under threads, but not process forks on this host." - }, { "file": "src/infra/outbound/message-action-runner.context.test.ts", "reason": "Terminates cleanly under threads, but not process forks on this host." @@ -83,34 +39,6 @@ "file": "src/infra/outbound/outbound-policy.test.ts", "reason": "Measured ~11% faster under threads than forks on this host while keeping the file green." }, - { - "file": "src/infra/outbound/outbound.test.ts", - "reason": "Measured ~14% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/plugins/web-search-providers.runtime.test.ts", - "reason": "Measured ~17% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/media-understanding/runtime.test.ts", - "reason": "Measured ~13% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/media-understanding/resolve.test.ts", - "reason": "Measured ~9% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/media-understanding/runner.skip-tiny-audio.test.ts", - "reason": "Measured ~23% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/media-understanding/runner.proxy.test.ts", - "reason": "Measured ~55% faster under threads than forks on this host while keeping the file green." - }, - { - "file": "src/media-understanding/runner.auto-audio.test.ts", - "reason": "Measured ~23% faster under threads than forks on this host while keeping the file green." - }, { "file": "src/media-understanding/runner.video.test.ts", "reason": "Measured ~25% faster under threads than forks on this host while keeping the file green."