diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index f563e6f3b46b..149eb7cc6d42 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -157,7 +157,12 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "channel-lifecycle": 23, // +1: shared ingress error factory projected through the deprecated message barrel. // +1: shared ingress retention defaults projected through the deprecated message barrel. - "channel-message": 131, + // +1: WhatsApp ack-policy bridge counted via channel-message's wildcard re-export. + "channel-message": 132, + // +2: Slack progress-draft render bridge (function + mode type). + "channel-outbound": 2, + // +2: WhatsApp ack-policy bridge (function + mode type). + "channel-feedback": 2, "channel-pairing": 0, "channel-policy": 7, "channel-send-result": 1, @@ -276,7 +281,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +11: Computer Use schemas/types plus parsers, compiler, and provider registration. // +6: Computer Use v2 action, result, and capability contracts. // +1: opaque channel participant evidence preservation without mint authority. - 4324, + // +6: load-only bridges for published pre-split plugin artifacts + // (voice-call/matrix runtime-doctor repair names, WhatsApp ack policy, + // Slack progress-draft render) so installed plugins survive upgrade (#124041 class). + 4330, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -346,7 +354,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -2: retire the dead progress-draft render reader; it counted twice via // channel-outbound and channel-message's wildcard re-export of it. // +4: Computer Use wire parsers, validator compiler, and provider registration. - 2574, + // +3: load-only bridges for published pre-split plugin artifacts + // (voice-call/matrix runtime-doctor repair names, WhatsApp ack policy, + // Slack progress-draft render) so installed plugins survive upgrade (#124041 class). + 2577, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( @@ -362,7 +373,10 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +7: restore still-existing deprecated inbound-dispatch compatibility re-exports. // +6: source-compatible harness contracts retained during the V2 migration window. // +4: shipped default-agent resolver projections retained during explicit-owner migration. - 1146, + // +5: load-only bridges for published pre-split plugin artifacts + // (voice-call/matrix runtime-doctor repair names, WhatsApp ack policy, + // Slack progress-draft render) so installed plugins survive upgrade (#124041 class). + 1151, env, ), publicWildcardReexports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/plugin-sdk/channel-feedback.ts b/src/plugin-sdk/channel-feedback.ts index 2b1f1b21cc03..f9649249d614 100644 --- a/src/plugin-sdk/channel-feedback.ts +++ b/src/plugin-sdk/channel-feedback.ts @@ -12,6 +12,50 @@ export { type AckReactionScope, } from "../channels/ack-reactions.js"; export { logAckFailure, logTypingFailure, type LogFn } from "../channels/logging.js"; +import { shouldAckReaction as sharedAckReactionGate } from "../channels/ack-reactions.js"; + +/** @deprecated Owner policy moved into the WhatsApp plugin (#121257). */ +export type WhatsAppAckReactionMode = "always" | "mentions" | "never"; + +/** + * @deprecated Load-only bridge: the published WhatsApp channel package + * (2026.7.2-beta.7 and earlier) imports this at module top level, so removing + * it makes the installed plugin fail to load after a core upgrade. Behavior + * preserved verbatim from the pre-#121257 owner. Remove once managed releases + * have replaced the old npm latest/extended-stable packages and their upgrade + * window has closed. + */ +export function shouldAckReactionForWhatsApp(params: { + emoji: string; + isDirect: boolean; + isGroup: boolean; + directEnabled: boolean; + groupMode: WhatsAppAckReactionMode; + wasMentioned: boolean; + groupActivated: boolean; +}): boolean { + if (!params.emoji) { + return false; + } + if (params.isDirect) { + return params.directEnabled; + } + if (!params.isGroup || params.groupMode === "never") { + return false; + } + if (params.groupMode === "always") { + return true; + } + return sharedAckReactionGate({ + scope: "group-mentions", + isDirect: false, + isGroup: true, + isMentionableGroup: true, + canDetectMention: true, + effectiveWasMentioned: params.wasMentioned, + shouldBypassMention: params.groupActivated, + }); +} export { missingTargetError } from "../infra/outbound/target-errors.js"; export { BUILD_TOOL_TOKENS, diff --git a/src/plugin-sdk/channel-outbound.ts b/src/plugin-sdk/channel-outbound.ts index 48ceaba953a7..5aef2c6e9e23 100644 --- a/src/plugin-sdk/channel-outbound.ts +++ b/src/plugin-sdk/channel-outbound.ts @@ -123,6 +123,30 @@ export { createChannelProgressDraftCompositor, createChannelProgressReceiptTracker, } from "../channels/progress-draft-compositor.js"; +import { + resolveChannelProgressDraftConfig as readProgressDraftConfig, + type StreamingCompatEntry as ProgressDraftCompatEntry, +} from "../channels/streaming.js"; + +/** @deprecated The streaming.progress.render key was retired (#122927). */ +export type ChannelProgressDraftRenderMode = "rich" | "text"; + +/** + * @deprecated Load-only bridge: the published Slack channel package + * (2026.7.2-beta.7 and earlier) imports this at module top level, so removing + * it makes the installed plugin fail to load after a core upgrade. The config + * key it read is retired and doctor strips it, so this resolves the same + * "text"/"rich" answer pre-doctor configs produced and the default otherwise. + * Remove once managed releases have replaced the old npm latest/extended-stable + * packages and their upgrade window has closed. + */ +export function resolveChannelProgressDraftRender( + entry: ProgressDraftCompatEntry | null | undefined, + defaultValue: ChannelProgressDraftRenderMode = "text", +): ChannelProgressDraftRenderMode { + const configured = (readProgressDraftConfig(entry) as { render?: unknown }).render; + return configured === "rich" || configured === "text" ? configured : defaultValue; +} export type { ChannelProgressDraftCompositorLine, ChannelProgressDraftCompositorSnapshot, diff --git a/src/plugin-sdk/runtime-doctor.test.ts b/src/plugin-sdk/runtime-doctor.test.ts index 2f224b3b4853..de7cab0350c2 100644 --- a/src/plugin-sdk/runtime-doctor.test.ts +++ b/src/plugin-sdk/runtime-doctor.test.ts @@ -1,16 +1,33 @@ import { describe, expect, it } from "vitest"; +import { createPluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js"; +import * as doctorRepairRuntime from "./doctor-repair-runtime.js"; import * as runtimeDoctorMigrations from "./runtime-doctor-migrations.js"; import * as legacyRuntimeDoctor from "./runtime-doctor.js"; describe("legacy runtime-doctor package facade", () => { - it("is exactly the dependency-light migration surface", () => { - expect(Object.keys(legacyRuntimeDoctor).toSorted()).toEqual( - Object.keys(runtimeDoctorMigrations).toSorted(), - ); + it("is exactly the migration surface plus the published-artifact repair bridge", () => { + // Bridge names ship for published pre-split doctor artifacts (#124041 + // class); delete them here alongside the runtime-doctor.ts bridge. + const expected = [ + ...new Set([ + ...Object.keys(runtimeDoctorMigrations), + ...Object.keys(doctorRepairRuntime), + "createPluginStateSyncKeyedStore", + ]), + ].toSorted(); + expect(Object.keys(legacyRuntimeDoctor).toSorted()).toEqual(expected); for (const key of Object.keys(runtimeDoctorMigrations)) { expect(legacyRuntimeDoctor[key as keyof typeof legacyRuntimeDoctor]).toBe( runtimeDoctorMigrations[key as keyof typeof runtimeDoctorMigrations], ); } + for (const key of Object.keys(doctorRepairRuntime)) { + expect(legacyRuntimeDoctor[key as keyof typeof legacyRuntimeDoctor]).toBe( + doctorRepairRuntime[key as keyof typeof doctorRepairRuntime], + ); + } + expect(legacyRuntimeDoctor.createPluginStateSyncKeyedStore).toBe( + createPluginStateSyncKeyedStore, + ); }); }); diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index d222b845f2d1..d38eb78f081f 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -3,3 +3,14 @@ * Current source must import `runtime-doctor-migrations` directly. */ export * from "./runtime-doctor-migrations.js"; +/** + * @deprecated Load-only bridge: published pre-split doctor artifacts + * (voice-call/matrix 2026.7.2-beta.7 and earlier) import these repair names + * from this subpath; without them the contract module fails to load and the + * plugin's doctor migrations silently never run. Remove once managed releases + * have replaced the old npm latest/extended-stable packages and their upgrade + * window has closed. Current source imports `doctor-repair-runtime` (heavy) + * and `plugin-state-store-runtime` directly. + */ +export * from "./doctor-repair-runtime.js"; +export { createPluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js"; diff --git a/src/plugin-sdk/shipped-export-bridges.test.ts b/src/plugin-sdk/shipped-export-bridges.test.ts new file mode 100644 index 000000000000..dfedd778f4d7 --- /dev/null +++ b/src/plugin-sdk/shipped-export-bridges.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { shouldAckReactionForWhatsApp, type WhatsAppAckReactionMode } from "./channel-feedback.js"; +import { resolveChannelProgressDraftRender } from "./channel-outbound.js"; + +// Behavior locks for the deprecated load-only bridges consumed by published +// pre-split plugin artifacts (2026.7.2-beta.7 and earlier). Delete alongside +// the bridges when their upgrade window closes. +describe("shouldAckReactionForWhatsApp bridge", () => { + const base = { + emoji: "👍", + isDirect: false, + isGroup: true, + directEnabled: true, + groupMode: "mentions" as WhatsAppAckReactionMode, + wasMentioned: false, + groupActivated: false, + }; + + const cases: Array<{ name: string; params: Partial; expected: boolean }> = [ + { name: "empty emoji never acks", params: { emoji: "" }, expected: false }, + { name: "direct follows directEnabled true", params: { isDirect: true }, expected: true }, + { + name: "direct follows directEnabled false", + params: { isDirect: true, directEnabled: false }, + expected: false, + }, + { name: "non-group non-direct never acks", params: { isGroup: false }, expected: false }, + { name: "group mode never", params: { groupMode: "never" }, expected: false }, + { name: "group mode always", params: { groupMode: "always" }, expected: true }, + { name: "mentions mode without mention", params: {}, expected: false }, + { name: "mentions mode with mention", params: { wasMentioned: true }, expected: true }, + { + name: "mentions mode activated group bypasses mention", + params: { groupActivated: true }, + expected: true, + }, + ]; + + it.each(cases)("$name", ({ params, expected }) => { + expect(shouldAckReactionForWhatsApp({ ...base, ...params })).toBe(expected); + }); +}); + +describe("resolveChannelProgressDraftRender bridge", () => { + it("returns the default when the retired render key is absent", () => { + expect(resolveChannelProgressDraftRender({})).toBe("text"); + expect(resolveChannelProgressDraftRender(undefined, "rich")).toBe("rich"); + }); + + it("passes through configured rich/text and ignores junk", () => { + expect(resolveChannelProgressDraftRender({ streaming: { progress: { render: "rich" } } })).toBe( + "rich", + ); + expect(resolveChannelProgressDraftRender({ streaming: { progress: { render: "text" } } })).toBe( + "text", + ); + expect( + resolveChannelProgressDraftRender({ streaming: { progress: { render: "bogus" } } }, "rich"), + ).toBe("rich"); + }); +}); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index a57ba6870428..7b66ace52a5e 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -1113,7 +1113,9 @@ describe("plugin-sdk subpath exports", () => { "shouldAckReaction", "DEFAULT_EMOJIS", ]); - expectSourceOmits("channel-feedback", [ + // The load-only WhatsApp bridge lives in the barrel for published + // pre-#121257 artifacts; the owner file stays free of channel policy. + expectSourceMentions("channel-feedback", [ "shouldAckReactionForWhatsApp", "WhatsAppAckReactionMode", ]); diff --git a/src/plugins/loader.native-module-loader.test.ts b/src/plugins/loader.native-module-loader.test.ts index 9e37489bf9c2..987d0ddcc551 100644 --- a/src/plugins/loader.native-module-loader.test.ts +++ b/src/plugins/loader.native-module-loader.test.ts @@ -123,6 +123,71 @@ function writePreManagedLlamaCppProviderFixture() { return pluginRoot; } +function writePreSplitSdkBridgeConsumerFixture() { + const pluginRoot = tempDirs.make("openclaw-plugin-loader-"); + fs.mkdirSync(path.join(pluginRoot, "dist")); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/sdk-bridge-consumer", + version: "2026.7.2-beta.7", + type: "module", + openclaw: { + extensions: ["./dist/index.js"], + runtimeExtensions: ["./dist/index.js"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "sdk-bridge-consumer", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + ), + "utf-8", + ); + // Import shapes copied from published 2026.7.2-beta.7 artifacts: + // voice-call/matrix doctor contracts (runtime-doctor), whatsapp ack policy + // (channel-feedback), slack progress-draft render (channel-outbound). + fs.writeFileSync( + path.join(pluginRoot, "dist", "index.js"), + [ + 'import { archiveLegacyStateSource, detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema, detectPluginInstallPathIssue, formatPluginInstallPathIssue, removePluginFromConfig, createPluginStateSyncKeyedStore } from "openclaw/plugin-sdk/runtime-doctor";', + 'import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback";', + 'import { resolveChannelProgressDraftRender } from "openclaw/plugin-sdk/channel-outbound";', + 'export default { id: "sdk-bridge-consumer", register() {', + " const bridged = [", + " archiveLegacyStateSource,", + " detectOpenClawStateDatabaseSchemaMigrations,", + " repairOpenClawStateDatabaseSchema,", + " detectPluginInstallPathIssue,", + " formatPluginInstallPathIssue,", + " removePluginFromConfig,", + " createPluginStateSyncKeyedStore,", + " shouldAckReactionForWhatsApp,", + " resolveChannelProgressDraftRender,", + " ];", + ' if (bridged.some((entry) => typeof entry !== "function")) throw new Error("missing bridge");', + "} };", + ].join("\n"), + "utf-8", + ); + return pluginRoot; +} + afterEach(() => { vi.resetModules(); vi.doUnmock("./plugin-module-loader-cache.js"); @@ -242,4 +307,30 @@ describe("createPluginModuleLoader", () => { expect(registry.plugins.find((plugin) => plugin.id === "llama-cpp")?.status).toBe("loaded"); }); + + it("loads published pre-split SDK bridge imports (doctor repair, WhatsApp ack, Slack render)", async () => { + const { loadOpenClawPlugins } = await importFreshModule( + import.meta.url, + "./loader.js?scope=sdk-bridge-upgrade-compat", + ); + const pluginRoot = writePreSplitSdkBridgeConsumerFixture(); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tempDirs.make("openclaw-plugin-loader-"); + + const registry = loadOpenClawPlugins({ + cache: false, + onlyPluginIds: ["sdk-bridge-consumer"], + config: { + plugins: { + enabled: true, + load: { paths: [pluginRoot] }, + allow: ["sdk-bridge-consumer"], + entries: { "sdk-bridge-consumer": { enabled: true } }, + }, + }, + }); + + const entry = registry.plugins.find((plugin) => plugin.id === "sdk-bridge-consumer"); + expect(entry?.error ?? null).toBeNull(); + expect(entry?.status).toBe("loaded"); + }); });