fix(telegram): stabilize reply dispatch runtime

Summary:
- Add a stable provider-dispatcher dist entry and legacy alias coverage for stale reply-dispatch chunks.
- Make Telegram reasoning stream previews transient after final delivery and harden visible-send reasoning sanitization.
- Document transient /reasoning stream behavior and credit @BunsDev in the changelog.

Verification:
- pnpm test src/agents/tools/message-tool.test.ts src/infra/tsdown-config.test.ts test/scripts/runtime-postbuild.test.ts extensions/telegram/src/bot-message-dispatch.test.ts src/plugin-sdk/channel-streaming.test.ts src/plugin-sdk/channel-entry-contract.test.ts
- OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test src/channels/plugins/module-loader.test.ts src/plugin-sdk/channel-entry-contract.test.ts
- pnpm exec oxfmt --check --threads=1 <changed files>
- git diff --check
- pnpm build
- GitHub PR checks for b8b7a91834
This commit is contained in:
Val Alexander
2026-05-04 01:07:57 -05:00
committed by GitHub
parent 7050af56d4
commit 21ac476904
14 changed files with 88 additions and 21 deletions

View File

@@ -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.

View File

@@ -318,6 +318,7 @@ curl "https://api.telegram.org/bot<bot_token>/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
</Accordion>

View File

@@ -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).

View File

@@ -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:

View File

@@ -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,

View File

@@ -1025,10 +1025,6 @@ export const dispatchTelegramMessage = async ({
continue;
}
if (info.kind === "final") {
if (reasoningLane.hasStreamedMessage) {
activePreviewLifecycleByLane.reasoning = "complete";
retainPreviewOnCleanupByLane.reasoning = true;
}
reasoningStepState.resetForNextStep();
}
}

View File

@@ -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 = [
{

View File

@@ -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 }) => {

View File

@@ -45,6 +45,26 @@ const EXPLICIT_TARGET_ACTIONS = new Set<ChannelMessageActionName>([
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]);
}
}

View File

@@ -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<string, NodeJS.RequireExtensions[string] | undefined>();
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<typeof import("./module-loader.js")>(
@@ -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));
});
});

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -242,6 +242,7 @@ function buildDockerE2eHarnessEntries(): Record<string, string> {
"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",