diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfc36e98e4..a6c96c5b649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc. - Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc. - Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins. +- Telegram: accept plugin-owned numeric forum-topic targets in the agent message tool and keep reply-dispatch provider chunks behind a real stable runtime alias during in-place package updates. Fixes #77137. Thanks @richardmqq. - Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc. - Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant. - Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index c4a320ffc26..c3d529ed5ad 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -777,11 +777,12 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.dms[""].historyLimit` - `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors. Inbound final-reply delivery also uses a bounded safe-send retry for Telegram pre-connect failures, but it does not retry ambiguous post-send network envelopes that could duplicate visible messages. - CLI send target can be numeric chat ID or username: + CLI and message-tool send targets can be numeric chat ID, username, or a forum topic target: ```bash openclaw message send --channel telegram --target 123456789 --message "hi" openclaw message send --channel telegram --target @name --message "hi" +openclaw message send --channel telegram --target -1001234567890:topic:42 --message "hi topic" ``` Telegram polls use `openclaw message poll` and support forum topics: diff --git a/docs/cli/message.md b/docs/cli/message.md index 891a4b1c203..d19dd50bdd2 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -27,7 +27,7 @@ Channel selection: Target formats (`--target`): - WhatsApp: E.164, group JID, or WhatsApp Channel/Newsletter JID (`...@newsletter`) -- Telegram: chat id or `@username` +- Telegram: chat id, `@username`, or forum topic target (`-1001234567890:topic:42`, or `--thread-id 42`) - Discord: `channel:` or `user:` (or `<@id>` mention; raw numeric ids are treated as channels) - Google Chat: `spaces/` or `users/` - Slack: `channel:` or `user:` (raw channel id is accepted) diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 56629b72447..0d1d1e40437 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -41,9 +41,9 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ ["server-close-DsVPJDIx.js", "server-close.runtime.js"], ["server-close-DvAvfgr8.js", "server-close.runtime.js"], // v2026.5.3 beta reply-dispatch lazy chunks. - ["provider-dispatcher-6EQEtc-t.js", "provider-dispatcher.js"], - ["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.js"], - ["provider-dispatcher-JG96SkLX.js", "provider-dispatcher.js"], + ["provider-dispatcher-6EQEtc-t.js", "provider-dispatcher.runtime.js"], + ["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.runtime.js"], + ["provider-dispatcher-JG96SkLX.js", "provider-dispatcher.runtime.js"], ]; const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [ { diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index c8945029367..0a933cd8e5d 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -427,6 +427,34 @@ describe("message tool path passthrough", () => { }); }); +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", diff --git a/src/auto-reply/reply/provider-dispatcher.runtime.ts b/src/auto-reply/reply/provider-dispatcher.runtime.ts new file mode 100644 index 00000000000..8b32b567499 --- /dev/null +++ b/src/auto-reply/reply/provider-dispatcher.runtime.ts @@ -0,0 +1 @@ +export * from "./provider-dispatcher.js"; diff --git a/src/infra/outbound/message-action-runner.core-send.test.ts b/src/infra/outbound/message-action-runner.core-send.test.ts index a9da9361e4c..0507df2da0e 100644 --- a/src/infra/outbound/message-action-runner.core-send.test.ts +++ b/src/infra/outbound/message-action-runner.core-send.test.ts @@ -123,4 +123,36 @@ describe("runMessageAction core send routing", () => { }), ); }); + + it("accepts Telegram numeric forum topic targets through plugin-owned grammar", async () => { + setActivePluginRegistry(createTestRegistry([])); + + const result = await runMessageAction({ + cfg: { + channels: { + telegram: { + botToken: "123:test", + }, + }, + } as OpenClawConfig, + action: "send", + params: { + channel: "telegram", + target: "-1001234567890:topic:42", + message: "topic hello", + }, + dryRun: true, + }); + + if (result.kind !== "send") { + throw new Error(`Expected send result, got ${result.kind}`); + } + expect(result.to).toBe("telegram:-1001234567890:topic:42"); + expect(result.payload).toEqual( + expect.objectContaining({ + to: "telegram:-1001234567890:topic:42", + dryRun: true, + }), + ); + }); }); diff --git a/src/infra/outbound/target-normalization.test.ts b/src/infra/outbound/target-normalization.test.ts index 7eacdb6d184..9350fc9633b 100644 --- a/src/infra/outbound/target-normalization.test.ts +++ b/src/infra/outbound/target-normalization.test.ts @@ -1,6 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn()); const getChannelPluginMock = vi.hoisted(() => vi.fn()); const getActivePluginChannelRegistryVersionMock = vi.hoisted(() => vi.fn()); @@ -15,7 +16,11 @@ let normalizeTargetForProvider: TargetNormalizationModule["normalizeTargetForPro let resetTargetNormalizerCacheForTests: TargetNormalizationModule["__testing"]["resetTargetNormalizerCacheForTests"]; vi.mock("../../channels/plugins/registry-loaded-read.js", () => ({ - getLoadedChannelPluginForRead: (...args: unknown[]) => getChannelPluginMock(...args), + getLoadedChannelPluginForRead: (...args: unknown[]) => getLoadedChannelPluginMock(...args), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), })); vi.mock("../../plugins/runtime.js", () => ({ @@ -38,6 +43,7 @@ beforeAll(async () => { }); beforeEach(() => { + getLoadedChannelPluginMock.mockReset(); getChannelPluginMock.mockReset(); getActivePluginChannelRegistryVersionMock.mockReset(); resetTargetNormalizerCacheForTests(); @@ -58,6 +64,7 @@ describe("normalizeTargetForProvider", () => { { provider: "unknown", setup: () => { + getLoadedChannelPluginMock.mockReturnValueOnce(undefined); getChannelPluginMock.mockReturnValueOnce(undefined); }, expected: "raw-id", @@ -66,6 +73,7 @@ describe("normalizeTargetForProvider", () => { provider: "alpha", setup: () => { getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(1); + getLoadedChannelPluginMock.mockReturnValueOnce(undefined); getChannelPluginMock.mockReturnValueOnce(undefined); }, expected: "raw-id", @@ -85,7 +93,7 @@ describe("normalizeTargetForProvider", () => { .mockReturnValueOnce(10) .mockReturnValueOnce(10) .mockReturnValueOnce(11); - getChannelPluginMock + getLoadedChannelPluginMock .mockReturnValueOnce({ messaging: { normalizeTarget: firstNormalizer }, }) @@ -97,14 +105,30 @@ describe("normalizeTargetForProvider", () => { expect(normalizeTargetForProvider("alpha", " def ")).toBe("DEF"); expect(normalizeTargetForProvider("alpha", " ghi ")).toBe("next:ghi"); - expect(getChannelPluginMock).toHaveBeenCalledTimes(2); + expect(getLoadedChannelPluginMock).toHaveBeenCalledTimes(2); + expect(getChannelPluginMock).not.toHaveBeenCalled(); expect(firstNormalizer).toHaveBeenCalledTimes(2); expect(secondNormalizer).toHaveBeenCalledTimes(1); }); + it("uses bundled/catalog target normalization when the channel is not loaded", () => { + getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(30); + getLoadedChannelPluginMock.mockReturnValueOnce(undefined); + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + normalizeTarget: (raw: string) => + raw.trim() === "-1001234567890:topic:42" ? "telegram:-1001234567890:topic:42" : undefined, + }, + }); + + expect(normalizeTargetForProvider("telegram", " -1001234567890:topic:42 ")).toBe( + "telegram:-1001234567890:topic:42", + ); + }); + it("returns undefined when the provider normalizer resolves to an empty value", () => { getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(20); - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { normalizeTarget: () => "", }, @@ -121,7 +145,7 @@ describe("resolveNormalizedTargetInput", () => { it("returns raw and normalized values", () => { getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(1); - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { normalizeTarget: (raw: string) => raw.trim().toUpperCase(), }, @@ -137,7 +161,7 @@ describe("resolveNormalizedTargetInput", () => { describe("looksLikeTargetId", () => { it("uses plugin looksLikeId when available", () => { const pluginLooksLikeId = vi.fn((raw: string, normalized: string) => raw !== normalized); - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { targetResolver: { looksLikeId: pluginLooksLikeId, @@ -158,17 +182,38 @@ describe("looksLikeTargetId", () => { it.each(["channel:C123", "@alice", "#general", "+15551234567", "conversation:abc", "foo@thread"])( "falls back to built-in id-like heuristics for %s", (raw) => { + getLoadedChannelPluginMock.mockReturnValueOnce(undefined); getChannelPluginMock.mockReturnValueOnce(undefined); expect(looksLikeTargetId({ channel: "workspace", raw })).toBe(true); }, ); + + it("uses bundled/catalog target id detection when the channel is not loaded", () => { + getLoadedChannelPluginMock.mockReturnValueOnce(undefined); + getChannelPluginMock.mockReturnValueOnce({ + messaging: { + targetResolver: { + looksLikeId: (raw: string, normalized?: string) => + raw === "-1001234567890:topic:42" && normalized === "telegram:-1001234567890:topic:42", + }, + }, + }); + + expect( + looksLikeTargetId({ + channel: "telegram", + raw: "-1001234567890:topic:42", + normalized: "telegram:-1001234567890:topic:42", + }), + ).toBe(true); + }); }); describe("maybeResolvePluginMessagingTarget", () => { const cfg = {} as OpenClawConfig; it("returns undefined when requireIdLike is set and the target is not id-like", async () => { - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { targetResolver: { looksLikeId: () => false, @@ -194,7 +239,7 @@ describe("maybeResolvePluginMessagingTarget", () => { kind: "group", display: "general", }); - getChannelPluginMock + getLoadedChannelPluginMock .mockReturnValueOnce({ messaging: { normalizeTarget: (raw: string) => raw.trim().toUpperCase(), @@ -234,7 +279,7 @@ describe("maybeResolvePluginMessagingTarget", () => { describe("buildTargetResolverSignature", () => { it("builds stable signatures from resolver hint and looksLikeId source", () => { const looksLikeId = (value: string) => value.startsWith("C"); - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { targetResolver: { hint: "Use channel id", @@ -244,7 +289,7 @@ describe("buildTargetResolverSignature", () => { }); const first = buildTargetResolverSignature("workspace"); - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { targetResolver: { hint: "Use channel id", @@ -258,7 +303,7 @@ describe("buildTargetResolverSignature", () => { }); it("changes when resolver metadata changes", () => { - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { targetResolver: { hint: "Use channel id", @@ -268,7 +313,7 @@ describe("buildTargetResolverSignature", () => { }); const first = buildTargetResolverSignature("workspace"); - getChannelPluginMock.mockReturnValueOnce({ + getLoadedChannelPluginMock.mockReturnValueOnce({ messaging: { targetResolver: { hint: "Use user id", diff --git a/src/infra/outbound/target-normalization.ts b/src/infra/outbound/target-normalization.ts index fbd89c7067a..a8d1909f3d6 100644 --- a/src/infra/outbound/target-normalization.ts +++ b/src/infra/outbound/target-normalization.ts @@ -1,4 +1,6 @@ +import { getChannelPlugin } from "../../channels/plugins/index.js"; import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js"; @@ -19,6 +21,10 @@ type TargetNormalizerCacheEntry = { const targetNormalizerCacheByChannelId = new Map(); +function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin | undefined { + return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId); +} + function resetTargetNormalizerCacheForTests(): void { targetNormalizerCacheByChannelId.clear(); } @@ -33,7 +39,7 @@ function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer { if (cached && cached.version === version) { return cached.normalizer; } - const plugin = getLoadedChannelPluginForRead(channelId); + const plugin = resolveChannelPluginForTargetRead(channelId); const normalizer = plugin?.messaging?.normalizeTarget; targetNormalizerCacheByChannelId.set(channelId, { version, @@ -85,7 +91,7 @@ export function looksLikeTargetId(params: { }): boolean { const normalizedInput = params.normalized ?? normalizeTargetForProvider(params.channel, params.raw); - const lookup = getLoadedChannelPluginForRead(params.channel)?.messaging?.targetResolver + const lookup = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver ?.looksLikeId; if (lookup) { return lookup(params.raw, normalizedInput ?? params.raw); @@ -117,7 +123,7 @@ export async function maybeResolvePluginMessagingTarget(params: { if (!normalizedInput) { return undefined; } - const resolver = getLoadedChannelPluginForRead(params.channel)?.messaging?.targetResolver; + const resolver = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver; if (!resolver?.resolveTarget) { return undefined; } @@ -150,7 +156,7 @@ export async function maybeResolvePluginMessagingTarget(params: { } export function buildTargetResolverSignature(channel: ChannelId): string { - const plugin = getLoadedChannelPluginForRead(channel); + const plugin = resolveChannelPluginForTargetRead(channel); const resolver = plugin?.messaging?.targetResolver; const hint = resolver?.hint ?? ""; const looksLike = resolver?.looksLikeId; diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index ba34e0f3402..dd240ee2523 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -14,17 +14,18 @@ const mocks = vi.hoisted(() => ({ listGroupsLive: vi.fn(), resolveTarget: vi.fn(), getChannelPlugin: vi.fn(), + getLoadedChannelPlugin: vi.fn(), getActivePluginChannelRegistryVersion: vi.fn(() => 1), })); vi.mock("../../channels/plugins/index.js", () => ({ - getLoadedChannelPlugin: (...args: unknown[]) => mocks.getChannelPlugin(...args), + getLoadedChannelPlugin: (...args: unknown[]) => mocks.getLoadedChannelPlugin(...args), getChannelPlugin: (...args: unknown[]) => mocks.getChannelPlugin(...args), normalizeChannelId: (value: string) => value, })); vi.mock("../../channels/plugins/registry-loaded-read.js", () => ({ - getLoadedChannelPluginForRead: (...args: unknown[]) => mocks.getChannelPlugin(...args), + getLoadedChannelPluginForRead: (...args: unknown[]) => mocks.getLoadedChannelPlugin(...args), })); vi.mock("../../plugins/runtime.js", () => ({ @@ -45,6 +46,10 @@ beforeEach(() => { mocks.listGroupsLive.mockReset(); mocks.resolveTarget.mockReset(); mocks.getChannelPlugin.mockReset(); + mocks.getLoadedChannelPlugin.mockReset(); + mocks.getLoadedChannelPlugin.mockImplementation((...args: unknown[]) => + mocks.getChannelPlugin(...args), + ); mocks.getActivePluginChannelRegistryVersion.mockReset(); mocks.getActivePluginChannelRegistryVersion.mockReturnValue(1); resetDirectoryCache(); @@ -153,6 +158,39 @@ describe("resolveMessagingTarget (directory fallback)", () => { expect(mocks.listGroupsLive).not.toHaveBeenCalled(); }); + it("uses catalog plugin target grammar for unloaded numeric topic ids", async () => { + mocks.getLoadedChannelPlugin.mockReturnValue(undefined); + mocks.getChannelPlugin.mockReturnValue({ + messaging: { + normalizeTarget: (raw: string) => + raw.trim() === "-1001234567890:topic:42" + ? "telegram:-1001234567890:topic:42" + : raw.trim() || undefined, + inferTargetChatType: ({ to }: { to: string }) => (to.includes("-100") ? "group" : "direct"), + targetResolver: { + looksLikeId: (_raw: string, normalized?: string) => + normalized === "telegram:-1001234567890:topic:42", + hint: "", + }, + }, + }); + + const result = await expectOkResolution({ + cfg, + channel: "telegram", + input: "-1001234567890:topic:42", + }); + + expect(result.target).toEqual({ + to: "telegram:-1001234567890:topic:42", + kind: "group", + display: "telegram:-1001234567890:topic:42", + source: "normalized", + }); + expect(mocks.listGroups).not.toHaveBeenCalled(); + expect(mocks.listGroupsLive).not.toHaveBeenCalled(); + }); + it("uses plugin chat-type inference for directory lookups and plugin fallback on miss", async () => { mocks.getChannelPlugin.mockReturnValue({ directory: { diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 943da197125..fa2384d7e52 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -90,7 +90,7 @@ describe("tsdown config", () => { "media-understanding/apply.runtime", "index", "commands/status.summary.runtime", - "auto-reply/reply/provider-dispatcher", + "provider-dispatcher.runtime", "plugins/provider-discovery.runtime", "plugins/provider-runtime.runtime", "plugins/runtime/index", @@ -112,12 +112,12 @@ describe("tsdown config", () => { ); }); - it("keeps reply dispatcher lazy runtime behind one stable dist entry", () => { + it("keeps reply dispatcher lazy runtime behind one root stable dist entry", () => { const distGraph = unifiedDistGraph(); expect(entrySources(distGraph as TsdownConfigEntry)).toEqual( expect.objectContaining({ - "auto-reply/reply/provider-dispatcher": "src/auto-reply/reply/provider-dispatcher.ts", + "provider-dispatcher.runtime": "src/auto-reply/reply/provider-dispatcher.runtime.ts", }), ); }); diff --git a/src/plugin-sdk/reply-dispatch-runtime.ts b/src/plugin-sdk/reply-dispatch-runtime.ts index d1c7c0bffe2..d9665d6e377 100644 --- a/src/plugin-sdk/reply-dispatch-runtime.ts +++ b/src/plugin-sdk/reply-dispatch-runtime.ts @@ -15,12 +15,12 @@ export type { ReplyPayload } from "./reply-payload.js"; export const dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher = async (params) => { const { dispatchReplyWithBufferedBlockDispatcher: dispatch } = - await import("../auto-reply/reply/provider-dispatcher.js"); + await import("../auto-reply/reply/provider-dispatcher.runtime.js"); return await dispatch(params); }; export const dispatchReplyWithDispatcher: DispatchReplyWithDispatcher = async (params) => { const { dispatchReplyWithDispatcher: dispatch } = - await import("../auto-reply/reply/provider-dispatcher.js"); + await import("../auto-reply/reply/provider-dispatcher.runtime.js"); return await dispatch(params); }; diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 5e28a737d09..707b73e3de7 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -227,6 +227,38 @@ describe("runtime postbuild static assets", () => { ); }); + it("rewrites reply-dispatch imports to the stable provider dispatcher runtime alias", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "provider-dispatcher.runtime-NewHash.js"), + 'export * from "./provider-dispatcher-ImplHash.js";\n', + "utf8", + ); + await fs.writeFile( + path.join(distDir, "reply-dispatch-runtime-OldHash.js"), + ['const dispatcher = () => import("./provider-dispatcher.runtime-NewHash.js");', ""].join( + "\n", + ), + "utf8", + ); + + rewriteRootRuntimeImportsToStableAliases({ rootDir }); + writeStableRootRuntimeAliases({ rootDir }); + writeLegacyRootRuntimeCompatAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "reply-dispatch-runtime-OldHash.js"), "utf8")).toBe( + ['const dispatcher = () => import("./provider-dispatcher.runtime.js");', ""].join("\n"), + ); + expect(await fs.readFile(path.join(distDir, "provider-dispatcher.runtime.js"), "utf8")).toBe( + 'export * from "./provider-dispatcher.runtime-NewHash.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "provider-dispatcher-6EQEtc-t.js"), "utf8")).toBe( + 'export * from "./provider-dispatcher.runtime.js";\n', + ); + }); + it("keeps hashed imports when a stable runtime alias would collide", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); @@ -294,8 +326,8 @@ describe("runtime postbuild static assets", () => { "utf8", ); await fs.writeFile( - path.join(distDir, "provider-dispatcher.js"), - 'export * from "./provider-dispatcher-NewHash.js";\n', + path.join(distDir, "provider-dispatcher.runtime.js"), + 'export * from "./provider-dispatcher.runtime-NewHash.js";\n', "utf8", ); @@ -308,7 +340,7 @@ describe("runtime postbuild static assets", () => { await fs.readFile(path.join(distDir, "runtime-plugins.runtime-CNAfmQRG.js"), "utf8"), ).toBe('export * from "./runtime-plugins.runtime.js";\n'); expect(await fs.readFile(path.join(distDir, "provider-dispatcher-6EQEtc-t.js"), "utf8")).toBe( - 'export * from "./provider-dispatcher.js";\n', + 'export * from "./provider-dispatcher.runtime.js";\n', ); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index d54c2f1bd84..366f1525b3b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -204,6 +204,7 @@ function buildCoreDistEntries(): Record { "agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts", "agents/models-config.runtime": "src/agents/models-config.runtime.ts", "cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts", + "provider-dispatcher.runtime": "src/auto-reply/reply/provider-dispatcher.runtime.ts", "server-close.runtime": "src/gateway/server-close.runtime.ts", "plugins/memory-state": "src/plugins/memory-state.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", @@ -242,7 +243,6 @@ function buildDockerE2eHarnessEntries(): Record { "src/agents/pi-embedded-runner/effective-tool-policy.ts", "agents/pi-embedded-runner/run/runtime-context-prompt": "src/agents/pi-embedded-runner/run/runtime-context-prompt.ts", - "auto-reply/reply/provider-dispatcher": "src/auto-reply/reply/provider-dispatcher.ts", "auto-reply/reply/commands-crestodian": "src/auto-reply/reply/commands-crestodian.ts", "cli/run-main": "src/cli/run-main.ts", "commitments/runtime": "src/commitments/runtime.ts",