diff --git a/CHANGELOG.md b/CHANGELOG.md index a1cc3fda725..48edeca2a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc. +- Telegram: keep reply-dispatch lazy provider runtime chunks behind stable dist names and delete `/reasoning stream` previews after final delivery so package updates and live reasoning drafts do not leave Telegram turns broken or noisy. Thanks @BunsDev. - Exec approvals: detect `env -S` split-string command-carrier risks when `-S`/`-s` is combined with other env short options, so approval explanations do not miss split payloads hidden behind `env -iS...`. Thanks @vincentkoc. - Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply. - Voice Call: mark realtime calls completed when the realtime provider closes normally, so Twilio/OpenAI/Google realtime stop events do not leave active call records behind. Thanks @vincentkoc. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 0f9762156c2..b1a502cf628 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -318,6 +318,7 @@ curl "https://api.telegram.org/bot/getUpdates" Telegram-only reasoning stream: - `/reasoning stream` sends reasoning to the live preview while generating + - the reasoning preview is deleted after final delivery; use `/reasoning on` when reasoning should remain visible - final answer is sent without reasoning text diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index c6ee474b716..e6a105892ae 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -166,7 +166,7 @@ OpenClaw can expose or hide model reasoning: - `/reasoning on|off|stream` controls visibility. - Reasoning content still counts toward token usage when produced by the model. -- Telegram supports reasoning stream into the draft bubble. +- Telegram supports reasoning stream into a transient draft bubble that is deleted after final delivery; use `/reasoning on` for persistent reasoning output. Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/reference/token-use). diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 9e8aab948bc..c12ddf011c3 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -162,7 +162,7 @@ Telegram: - Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics. - Sends a fresh final message instead of editing in place when a preview has been visible for about one minute, then cleans up the preview so Telegram's timestamp reflects reply completion. - Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming). -- `/reasoning stream` can write reasoning to preview. +- `/reasoning stream` can write reasoning to a transient preview that is deleted after final delivery. Discord: diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index e21b40a537c..4892d00458a 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -2862,7 +2862,7 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); - it("keeps reasoning preview message when reasoning is streamed but final is answer-only", async () => { + it("clears reasoning preview message when reasoning is streamed but final is answer-only", async () => { const { reasoningDraftStream } = setupDraftStreams({ answerMessageId: 999, reasoningMessageId: 111, @@ -2887,7 +2887,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(reasoningDraftStream.update).toHaveBeenCalledWith( "Reasoning:\n_Word: strawberry. r appears at 3, 8, 9._", ); - expect(reasoningDraftStream.clear).not.toHaveBeenCalled(); + expect(reasoningDraftStream.clear).toHaveBeenCalledTimes(1); expect(editMessageTelegram).toHaveBeenCalledWith( 123, 999, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index ffda126b665..e5a83311db5 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -1025,10 +1025,6 @@ export const dispatchTelegramMessage = async ({ continue; } if (info.kind === "final") { - if (reasoningLane.hasStreamedMessage) { - activePreviewLifecycleByLane.reasoning = "complete"; - retainPreviewOnCleanupByLane.reasoning = true; - } reasoningStepState.resetForNextStep(); } } diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 83a45b594f4..56629b72447 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -40,6 +40,10 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ // gateway may resolve these only after an npm package tree replacement. ["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"], ]; 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 54fa1b90b0a..9c541a769a3 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -1107,6 +1107,20 @@ describe("message tool reasoning tag sanitization", () => { target: "signal:+15551234567", channel: "signal", }, + { + field: "message", + input: "Reasoning:\n_internal plan_\n\nVisible answer", + expected: "Visible answer", + target: "telegram:123", + channel: "telegram", + }, + { + field: "message", + input: "Reasoning:\n_internal plan_\n_more internal notes_", + expected: "", + target: "telegram:123", + channel: "telegram", + }, ])( "sanitizes reasoning tags in $field before sending", async ({ channel, target, field, input, expected }) => { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 74a07d0812d..c74e6480c98 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -45,6 +45,26 @@ const EXPLICIT_TARGET_ACTIONS = new Set([ function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean { return EXPLICIT_TARGET_ACTIONS.has(action); } + +function stripFormattedReasoningMessage(text: string): string { + const stripped = stripReasoningTagsFromText(text); + const lines = stripped.split(/\r?\n/u); + if (lines[0]?.trim() !== "Reasoning:") { + return stripped; + } + + let index = 1; + while (index < lines.length) { + const trimmed = lines[index]?.trim() ?? ""; + if (!trimmed || (trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 2)) { + index += 1; + continue; + } + break; + } + return lines.slice(index).join("\n").trim(); +} + function buildRoutingSchema() { return { channel: Type.Optional(Type.String()), @@ -692,7 +712,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { // in tool arguments, and the messaging tool send path has no other tag filtering. for (const field of ["text", "content", "message", "caption"]) { if (typeof params[field] === "string") { - params[field] = stripReasoningTagsFromText(params[field]); + params[field] = stripFormattedReasoningMessage(params[field]); } } diff --git a/src/channels/plugins/module-loader.test.ts b/src/channels/plugins/module-loader.test.ts index 4aa37e99b2e..e9a9b0cca90 100644 --- a/src/channels/plugins/module-loader.test.ts +++ b/src/channels/plugins/module-loader.test.ts @@ -12,6 +12,7 @@ const tempDirs: string[] = []; const pluginModuleLoaderJitiFactoryOverrideKey = Symbol.for( "openclaw.pluginModuleLoaderJitiFactoryOverride", ); +const testRequire = createRequire(import.meta.url); afterEach(() => { for (const tempDir of tempDirs.splice(0)) { @@ -87,6 +88,12 @@ describe("channel plugin module loader helpers", () => { target, })); const createJiti = vi.fn(() => loadWithJiti); + const sourceExtensions = [".ts", ".tsx", ".mts", ".cts"] as const; + const sourceHooks = new Map(); + for (const extension of sourceExtensions) { + sourceHooks.set(extension, testRequire.extensions[extension]); + delete testRequire.extensions[extension]; + } vi.resetModules(); stubPluginModuleLoaderJitiFactory(createJiti as unknown as PluginModuleLoaderFactory); const loaderModule = await importFreshModule( @@ -98,9 +105,6 @@ describe("channel plugin module loader helpers", () => { fs.mkdirSync(path.dirname(modulePath), { recursive: true }); fs.writeFileSync(modulePath, "export const ok = true;\n", "utf8"); - const testRequire = createRequire(import.meta.url); - const originalTsHook = testRequire.extensions[".ts"]; - delete testRequire.extensions[".ts"]; try { expect( loaderModule.loadChannelPluginModule({ @@ -111,16 +115,20 @@ describe("channel plugin module loader helpers", () => { loadedBy: "jiti", target: fs.realpathSync.native(modulePath), }); + expect(createJiti).toHaveBeenCalledOnce(); + expect(createJiti).toHaveBeenCalledWith( + expect.stringContaining("module-loader.ts"), + expect.objectContaining({ tryNative: false }), + ); + expect(loadWithJiti).toHaveBeenCalledWith(fs.realpathSync.native(modulePath)); } finally { - if (originalTsHook) { - testRequire.extensions[".ts"] = originalTsHook; + for (const [extension, hook] of sourceHooks) { + if (hook) { + testRequire.extensions[extension] = hook; + } else { + delete testRequire.extensions[extension]; + } } } - expect(createJiti).toHaveBeenCalledOnce(); - expect(createJiti).toHaveBeenCalledWith( - expect.stringContaining("module-loader.ts"), - expect.objectContaining({ tryNative: false }), - ); - expect(loadWithJiti).toHaveBeenCalledWith(fs.realpathSync.native(modulePath)); }); }); diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index 40f7a065694..943da197125 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -90,6 +90,7 @@ describe("tsdown config", () => { "media-understanding/apply.runtime", "index", "commands/status.summary.runtime", + "auto-reply/reply/provider-dispatcher", "plugins/provider-discovery.runtime", "plugins/provider-runtime.runtime", "plugins/runtime/index", @@ -111,6 +112,16 @@ describe("tsdown config", () => { ); }); + it("keeps reply dispatcher lazy runtime behind one 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", + }), + ); + }); + it("routes gateway run-loop lifecycle imports through the stable runtime boundary", () => { const importSpecifiers = [ ...readGatewayRunLoopSource().matchAll(/import\(["']([^"']+)["']\)/gu), diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index eb76de7d88f..5cdfc8f728a 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -635,7 +635,10 @@ function compactProgressLineDetail(detail: string, maxChars: number): string { function removeUnbalancedInlineBackticks(value: string): string { const backtickCount = Array.from(value).filter((char) => char === "`").length; - return backtickCount % 2 === 1 ? value.replaceAll("`", "") : value; + if (backtickCount % 2 === 0) { + return value; + } + return value.trimStart().startsWith("`") ? value.replaceAll("`", "'") : value.replaceAll("`", ""); } function compactChannelProgressDraftLine(line: string, maxChars: number): string { diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index d4592ea6a50..5e28a737d09 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -293,6 +293,11 @@ describe("runtime postbuild static assets", () => { 'export * from "./runtime-plugins.runtime-NewHash.js";\n', "utf8", ); + await fs.writeFile( + path.join(distDir, "provider-dispatcher.js"), + 'export * from "./provider-dispatcher-NewHash.js";\n', + "utf8", + ); writeLegacyRootRuntimeCompatAliases({ rootDir }); @@ -302,6 +307,9 @@ describe("runtime postbuild static assets", () => { expect( 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', + ); }); it("writes compatibility aliases for previous gateway shutdown chunk names", async () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index c3069661654..d54c2f1bd84 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -242,6 +242,7 @@ 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",