mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1025,10 +1025,6 @@ export const dispatchTelegramMessage = async ({
|
||||
continue;
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
if (reasoningLane.hasStreamedMessage) {
|
||||
activePreviewLifecycleByLane.reasoning = "complete";
|
||||
retainPreviewOnCleanupByLane.reasoning = true;
|
||||
}
|
||||
reasoningStepState.resetForNextStep();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user