mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-22 11:34:02 +00:00
fix(telegram): gate native tool progress drafts
This commit is contained in:
committed by
Ayaan Zaidi
parent
7cc4258dd5
commit
a433cef05f
@@ -7,7 +7,7 @@ Verified against Telegram Bot API 10.0, May 8 2026.
|
||||
## Streaming
|
||||
|
||||
- Do not reintroduce `sendMessageDraft` for answer streaming. Telegram drafts are ephemeral 30-second previews in private chats; final delivery still requires a separate `sendMessage`. OpenClaw uses `sendMessage` plus `editMessageText`, then finalizes in place so the user sees one persistent answer.
|
||||
- `sendMessageDraft` is allowed only for transient private-chat tool progress/Thinking previews. Never route answer text, reasoning text, or final delivery through native drafts.
|
||||
- `sendMessageDraft` is allowed only for explicitly enabled transient private-chat tool progress/Thinking previews. Keep it default-off/canary-gated. Never route answer text, reasoning text, or final delivery through native drafts.
|
||||
- Streaming owns one visible preview message. Edit it forward. Do not send an extra final bubble unless the final edit genuinely failed.
|
||||
- Keep the first-preview debounce. If a provider sends token-sized deltas, coalesce them into cumulative preview text instead of removing the debounce.
|
||||
- Respect Telegram limits in the Telegram layer. Text over 4096 chars chains into continuation messages. Polls keep the current Bot API 12-option cap.
|
||||
|
||||
@@ -307,7 +307,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
- direct chats: preview message + `editMessageText`
|
||||
- groups/topics: preview message + `editMessageText`
|
||||
- direct-chat tool progress: native ephemeral `sendMessageDraft` Thinking/status preview when the Bot API supports it
|
||||
- direct-chat tool progress: optional native ephemeral `sendMessageDraft` Thinking/status preview when explicitly enabled and the Bot API supports it
|
||||
|
||||
Requirement:
|
||||
|
||||
@@ -319,7 +319,24 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later. To keep the edited preview for answer text but hide tool-progress lines, set:
|
||||
|
||||
In direct chats, supported Telegram Bot API clients use native ephemeral drafts for these tool-progress lines. This shows an immediate Telegram-native Thinking/status preview without persisting tool chatter into the chat history. As soon as assistant answer text starts, OpenClaw stops updating the native draft and continues with the normal persistent answer preview/final delivery path. If `sendMessageDraft` is unavailable or rejected, OpenClaw silently falls back to the edited preview behavior.
|
||||
In direct chats, supported Telegram Bot API clients can use native ephemeral drafts for these tool-progress lines. This shows an immediate Telegram-native Thinking/status preview without persisting tool chatter into the chat history. As soon as assistant answer text starts, OpenClaw stops updating the native draft and continues with the normal persistent answer preview/final delivery path. If `sendMessageDraft` is unavailable or rejected, OpenClaw silently falls back to the edited preview behavior. This native draft lane is off by default; enable it only for trusted DM canaries first:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"streaming": {
|
||||
"mode": "partial",
|
||||
"preview": {
|
||||
"toolProgress": true,
|
||||
"nativeToolProgress": true,
|
||||
"nativeToolProgressAllowFrom": ["123456789"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1250,7 +1250,12 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "partial",
|
||||
preview: { nativeToolProgress: true, nativeToolProgressAllowFrom: ["123"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createNativeTelegramToolProgressDraft).toHaveBeenCalledWith(
|
||||
@@ -1267,6 +1272,62 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(nativeDraft.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps native DM drafts off by default", async () => {
|
||||
const nativeDraft = createNativeToolProgressDraft();
|
||||
createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver({ text: "Done answer." }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expect(createNativeTelegramToolProgressDraft).not.toHaveBeenCalled();
|
||||
expect(nativeDraft.update).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, expect.stringContaining("Exec"));
|
||||
expect(answerDraftStream.update).toHaveBeenLastCalledWith("Done answer.");
|
||||
});
|
||||
|
||||
it("honors the native DM draft allowlist", async () => {
|
||||
const nativeDraft = createNativeToolProgressDraft();
|
||||
createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver({ text: "Done answer." }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "partial",
|
||||
preview: {
|
||||
nativeToolProgress: true,
|
||||
nativeToolProgressAllowFrom: ["999"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createNativeTelegramToolProgressDraft).not.toHaveBeenCalled();
|
||||
expect(nativeDraft.update).not.toHaveBeenCalled();
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, expect.stringContaining("Exec"));
|
||||
expect(answerDraftStream.update).toHaveBeenLastCalledWith("Done answer.");
|
||||
});
|
||||
|
||||
it("falls back to edited preview tool progress when native DM draft update fails", async () => {
|
||||
const nativeDraft = createNativeToolProgressDraft(false);
|
||||
createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
|
||||
@@ -1282,7 +1343,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", preview: { nativeToolProgress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeDraft.update).toHaveBeenCalledWith(expect.stringContaining("Exec"));
|
||||
@@ -1305,7 +1368,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", preview: { nativeToolProgress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeDraft.update).not.toHaveBeenCalled();
|
||||
@@ -1324,7 +1389,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", preview: { nativeToolProgress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeDraft.update).not.toHaveBeenCalled();
|
||||
@@ -1350,7 +1417,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", preview: { nativeToolProgress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeDraft.update).not.toHaveBeenCalled();
|
||||
@@ -1382,7 +1451,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
threadSpec: { id: undefined, scope: "none" },
|
||||
}),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", preview: { nativeToolProgress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(createNativeTelegramToolProgressDraft).not.toHaveBeenCalled();
|
||||
@@ -1403,7 +1474,9 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", preview: { nativeToolProgress: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeDraft.update).not.toHaveBeenCalledWith(
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
mergeChannelProgressDraftLine,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingPreviewNativeToolProgress,
|
||||
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveTranscriptBackedChannelFinalText,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
sleepWithAbort,
|
||||
} from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveTelegramConfigReasoningDefault } from "./agent-config.js";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||
import type { TelegramMessageContext } from "./bot-message-context.js";
|
||||
import {
|
||||
@@ -186,6 +189,21 @@ function canUseNativeToolProgressDraft(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function canUseNativeToolProgressDraftForChat(params: {
|
||||
telegramCfg: TelegramAccountConfig;
|
||||
chatId: number | string;
|
||||
}): boolean {
|
||||
if (!resolveChannelStreamingPreviewNativeToolProgress(params.telegramCfg)) {
|
||||
return false;
|
||||
}
|
||||
const allowFrom = resolveChannelStreamingPreviewNativeToolProgressAllowFrom(params.telegramCfg);
|
||||
if (!allowFrom || allowFrom.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const normalized = normalizeAllowFrom(allowFrom);
|
||||
return normalized.hasWildcard || normalized.entries.includes(String(params.chatId));
|
||||
}
|
||||
|
||||
async function resolveStickerVisionSupport(cfg: OpenClawConfig, agentId: string) {
|
||||
try {
|
||||
const catalog = await loadModelCatalog({ config: cfg });
|
||||
@@ -585,7 +603,11 @@ export const dispatchTelegramMessage = async ({
|
||||
const streamToolProgressEnabled =
|
||||
Boolean(answerLane.stream) && resolveChannelStreamingPreviewToolProgress(telegramCfg);
|
||||
const nativeToolProgressDraft =
|
||||
streamToolProgressEnabled && !isRoomEvent && !isGroup && threadSpec.scope === "dm"
|
||||
streamToolProgressEnabled &&
|
||||
!isRoomEvent &&
|
||||
!isGroup &&
|
||||
threadSpec.scope === "dm" &&
|
||||
canUseNativeToolProgressDraftForChat({ telegramCfg, chatId })
|
||||
? (
|
||||
telegramDeps.createNativeTelegramToolProgressDraft ??
|
||||
createNativeTelegramToolProgressDraft
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -63,6 +63,13 @@ export type ChannelStreamingPreviewConfig = {
|
||||
toolProgress?: boolean;
|
||||
/** Command/exec progress detail in the preview. "raw" preserves released behavior; "status" shows only the tool label. Default: "raw". */
|
||||
commandText?: ChannelStreamingCommandTextMode;
|
||||
/**
|
||||
* Use channel-native ephemeral UI for preview tool progress when supported.
|
||||
* Default: false.
|
||||
*/
|
||||
nativeToolProgress?: boolean;
|
||||
/** Optional sender/user ID allowlist for native preview tool progress. */
|
||||
nativeToolProgressAllowFrom?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type ChannelStreamingBlockConfig = {
|
||||
|
||||
@@ -85,6 +85,8 @@ const ChannelStreamingPreviewSchema = z
|
||||
chunk: BlockStreamingChunkSchema.optional(),
|
||||
toolProgress: z.boolean().optional(),
|
||||
commandText: z.enum(["raw", "status"]).optional(),
|
||||
nativeToolProgress: z.boolean().optional(),
|
||||
nativeToolProgressAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
.strict();
|
||||
const ChannelStreamingProgressSchema = z
|
||||
|
||||
@@ -48,6 +48,13 @@ function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function asStringNumberArray(value: unknown): Array<string | number> | undefined {
|
||||
return Array.isArray(value) &&
|
||||
value.every((entry) => typeof entry === "string" || typeof entry === "number")
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
||||
}
|
||||
@@ -640,6 +647,21 @@ export function resolveChannelStreamingPreviewCommandText(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingPreviewNativeToolProgress(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
defaultValue = false,
|
||||
): boolean {
|
||||
const config = getChannelStreamingConfigObject(entry);
|
||||
return asBoolean(config?.preview?.nativeToolProgress) ?? defaultValue;
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingPreviewNativeToolProgressAllowFrom(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): Array<string | number> | undefined {
|
||||
const config = getChannelStreamingConfigObject(entry);
|
||||
return asStringNumberArray(config?.preview?.nativeToolProgressAllowFrom);
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingSuppressDefaultToolProgressMessages(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
options?: {
|
||||
|
||||
Reference in New Issue
Block a user