refactor: simplify Telegram preview streaming to single boolean (#22012)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a4017d3b94
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Ayaan Zaidi
2026-02-21 15:19:13 +05:30
committed by GitHub
parent e1cb73cdeb
commit 677384c519
13 changed files with 116 additions and 137 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Security/Unused Dependencies: remove unused plugin-local `openclaw` devDependencies from `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task` after removing this dependency from build-time requirements. (#22495) Thanks @vincentkoc. - Security/Unused Dependencies: remove unused plugin-local `openclaw` devDependencies from `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task` after removing this dependency from build-time requirements. (#22495) Thanks @vincentkoc.
- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204. - Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204.
- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin. - Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin.
- Telegram/Streaming: simplify preview streaming config to `channels.telegram.streaming` (boolean), auto-map legacy `streamMode` values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus.
- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky. - iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky.
- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky. - iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky.
- iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky. - iOS/Tests: cover IPv4-mapped IPv6 loopback in manual TLS policy tests for connect validation paths. (#22045) Thanks @mbelinky.

View File

@@ -21,7 +21,7 @@ title: grammY
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls). - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel. - **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`. - **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
- **Live stream preview:** optional `channels.telegram.streamMode` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming. - **Live stream preview:** optional `channels.telegram.streaming` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
Open questions Open questions

View File

@@ -226,21 +226,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Requirement: Requirement:
- `channels.telegram.streamMode` is not `"off"` (default: `"partial"`) - `channels.telegram.streaming` is `true` (default)
- legacy `channels.telegram.streamMode` values are auto-mapped to `streaming`
Modes:
- `off`: no live preview
- `partial`: frequent preview updates from partial text
- `block`: chunked preview updates using `channels.telegram.draftChunk`
`draftChunk` defaults for `streamMode: "block"`:
- `minChars: 200`
- `maxChars: 800`
- `breakPreference: "paragraph"`
`maxChars` is clamped by `channels.telegram.textChunkLimit`.
This works in direct chats and groups/topics. This works in direct chats and groups/topics.
@@ -248,7 +235,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
`streamMode` is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming. Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
Telegram-only reasoning stream: Telegram-only reasoning stream:
@@ -721,7 +708,7 @@ Primary reference:
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streamMode`: `off | partial | block` (live stream preview). - `channels.telegram.streaming`: `true | false` (live stream preview; default: true).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. - `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
@@ -745,7 +732,7 @@ Telegram-specific high-signal fields:
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*` - access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`
- command/menu: `commands.native`, `customCommands` - command/menu: `commands.native`, `customCommands`
- threading/replies: `replyToMode` - threading/replies: `replyToMode`
- streaming: `streamMode` (preview), `draftChunk`, `blockStreaming` - streaming: `streaming` (preview), `blockStreaming`
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` - formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy` - media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` - webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`

View File

@@ -100,7 +100,7 @@ This maps to:
**Channel note:** For non-Telegram channels, block streaming is **off unless** **Channel note:** For non-Telegram channels, block streaming is **off unless**
`*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview `*.blockStreaming` is explicitly set to `true`. Telegram can stream a live preview
(`channels.telegram.streamMode`) without block replies. (`channels.telegram.streaming`) without block replies.
Config location reminder: the `blockStreaming*` defaults live under Config location reminder: the `blockStreaming*` defaults live under
`agents.defaults`, not the root config. `agents.defaults`, not the root config.
@@ -110,11 +110,7 @@ Config location reminder: the `blockStreaming*` defaults live under
Telegram is the only channel with live preview streaming: Telegram is the only channel with live preview streaming:
- Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates). - Uses Bot API `sendMessage` (first update) + `editMessageText` (subsequent updates).
- `channels.telegram.streamMode: "partial" | "block" | "off"`. - `channels.telegram.streaming: true | false` (default: `true`).
- `partial`: preview updates with latest stream text.
- `block`: preview updates in chunked blocks (same chunker rules).
- `off`: no preview streaming.
- Preview chunk config (only for `streamMode: "block"`): `channels.telegram.draftChunk` (defaults: `minChars: 200`, `maxChars: 800`).
- Preview streaming is separate from block streaming. - Preview streaming is separate from block streaming.
- When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming. - When Telegram block streaming is explicitly enabled, preview streaming is skipped to avoid double-streaming.
- Text-only finals are applied by editing the preview message in place. - Text-only finals are applied by editing the preview message in place.
@@ -124,8 +120,7 @@ Telegram is the only channel with live preview streaming:
``` ```
Telegram Telegram
└─ sendMessage (temporary preview message) └─ sendMessage (temporary preview message)
─ streamMode=partial → edit latest text ─ streaming=true → edit latest text
└─ streamMode=block → chunker + edit updates
└─ final text-only reply → final edit on same message └─ final text-only reply → final edit on same message
└─ fallback: cleanup preview + normal final delivery (media/complex) └─ fallback: cleanup preview + normal final delivery (media/complex)
``` ```

View File

@@ -151,12 +151,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
historyLimit: 50, historyLimit: 50,
replyToMode: "first", // off | first | all replyToMode: "first", // off | first | all
linkPreview: true, linkPreview: true,
streamMode: "partial", // off | partial | block streaming: true, // live preview on/off (default true)
draftChunk: {
minChars: 200,
maxChars: 800,
breakPreference: "paragraph", // paragraph | newline | sentence
},
actions: { reactions: true, sendMessage: true }, actions: { reactions: true, sendMessage: true },
reactionNotifications: "own", // off | own | all reactionNotifications: "own", // off | own | all
mediaMaxMb: 5, mediaMaxMb: 5,

View File

@@ -378,11 +378,46 @@ describe("legacy config detection", () => {
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist"); expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist");
} }
}); });
it("defaults telegram.streamMode to partial when telegram section exists", async () => { it("defaults telegram.streaming to true when telegram section exists", async () => {
const res = validateConfigObject({ channels: { telegram: {} } }); const res = validateConfigObject({ channels: { telegram: {} } });
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
if (res.ok) { if (res.ok) {
expect(res.config.channels?.telegram?.streamMode).toBe("partial"); expect(res.config.channels?.telegram?.streaming).toBe(true);
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
}
});
it("migrates legacy telegram.streamMode=off to streaming=false", async () => {
const res = validateConfigObject({ channels: { telegram: { streamMode: "off" } } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.streaming).toBe(false);
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
}
});
it("migrates legacy telegram.streamMode=block to streaming=true", async () => {
const res = validateConfigObject({ channels: { telegram: { streamMode: "block" } } });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.streaming).toBe(true);
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
}
});
it("migrates legacy telegram.accounts.*.streamMode to streaming", async () => {
const res = validateConfigObject({
channels: {
telegram: {
accounts: {
ops: {
streamMode: "off",
},
},
},
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.channels?.telegram?.accounts?.ops?.streaming).toBe(false);
expect(res.config.channels?.telegram?.accounts?.ops?.streamMode).toBeUndefined();
} }
}); });
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => { it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {

View File

@@ -391,14 +391,8 @@ export const FIELD_HELP: Record<string, string> = {
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
"channels.telegram.dmPolicy": "channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streamMode": "channels.telegram.streaming":
"Live stream preview mode for Telegram replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessageText.", "Enable Telegram live stream preview via message edits (default: true; legacy streamMode auto-maps here).",
"channels.telegram.draftChunk.minChars":
'Minimum chars before emitting a Telegram stream preview update when channels.telegram.streamMode="block" (default: 200).',
"channels.telegram.draftChunk.maxChars":
'Target max size for a Telegram stream preview chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).',
"channels.telegram.draftChunk.breakPreference":
"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.",
"channels.discord.streamMode": "channels.discord.streamMode":
"Live stream preview mode for Discord replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessage.", "Live stream preview mode for Discord replies (off | partial | block). Separate from block streaming; uses sendMessage + editMessage.",
"channels.discord.draftChunk.minChars": "channels.discord.draftChunk.minChars":

View File

@@ -263,10 +263,7 @@ export const FIELD_LABELS: Record<string, string> = {
...IRC_FIELD_LABELS, ...IRC_FIELD_LABELS,
"channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.botToken": "Telegram Bot Token",
"channels.telegram.dmPolicy": "Telegram DM Policy", "channels.telegram.dmPolicy": "Telegram DM Policy",
"channels.telegram.streamMode": "Telegram Stream Mode", "channels.telegram.streaming": "Telegram Streaming",
"channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
"channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
"channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference",
"channels.telegram.retry.attempts": "Telegram Retry Attempts", "channels.telegram.retry.attempts": "Telegram Retry Attempts",
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",

View File

@@ -95,13 +95,15 @@ export type TelegramAccountConfig = {
textChunkLimit?: number; textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline"; chunkMode?: "length" | "newline";
/** Enable live stream preview via message edits (default: true). */
streaming?: boolean;
/** Disable block streaming for this account. */ /** Disable block streaming for this account. */
blockStreaming?: boolean; blockStreaming?: boolean;
/** Chunking config for Telegram stream previews in `streamMode: "block"`. */ /** @deprecated Legacy chunking config from `streamMode: "block"`; ignored after migration. */
draftChunk?: BlockStreamingChunkConfig; draftChunk?: BlockStreamingChunkConfig;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig; blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Telegram stream preview mode (off|partial|block). Default: partial. */ /** @deprecated Legacy key; migrated automatically to `streaming` boolean. */
streamMode?: "off" | "partial" | "block"; streamMode?: "off" | "partial" | "block";
mediaMaxMb?: number; mediaMaxMb?: number;
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */ /** Telegram API client timeout in seconds (grammY ApiClientOptions). */

View File

@@ -99,6 +99,27 @@ const validateTelegramCustomCommands = (
} }
}; };
function normalizeTelegramStreamingConfig(value: {
streaming?: boolean;
streamMode?: "off" | "partial" | "block";
}) {
if (typeof value.streaming === "boolean") {
delete value.streamMode;
return;
}
if (value.streamMode === "off") {
value.streaming = false;
delete value.streamMode;
return;
}
if (value.streamMode === "partial" || value.streamMode === "block") {
value.streaming = true;
delete value.streamMode;
return;
}
value.streaming = true;
}
export const TelegramAccountSchemaBase = z export const TelegramAccountSchemaBase = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
@@ -122,10 +143,12 @@ export const TelegramAccountSchemaBase = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(), chunkMode: z.enum(["length", "newline"]).optional(),
streaming: z.boolean().optional(),
blockStreaming: z.boolean().optional(), blockStreaming: z.boolean().optional(),
draftChunk: BlockStreamingChunkSchema.optional(), draftChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
streamMode: z.enum(["off", "partial", "block"]).optional().default("partial"), // Legacy key kept for automatic migration to `streaming`.
streamMode: z.enum(["off", "partial", "block"]).optional(),
mediaMaxMb: z.number().positive().optional(), mediaMaxMb: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema, retry: RetryConfigSchema,
@@ -159,6 +182,7 @@ export const TelegramAccountSchemaBase = z
.strict(); .strict();
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
normalizeTelegramStreamingConfig(value);
requireOpenAllowFrom({ requireOpenAllowFrom({
policy: value.dmPolicy, policy: value.dmPolicy,
allowFrom: value.allowFrom, allowFrom: value.allowFrom,
@@ -173,6 +197,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(), accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
normalizeTelegramStreamingConfig(value);
requireOpenAllowFrom({ requireOpenAllowFrom({
policy: value.dmPolicy, policy: value.dmPolicy,
allowFrom: value.allowFrom, allowFrom: value.allowFrom,

View File

@@ -193,7 +193,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1); expect(draftStream.clear).toHaveBeenCalledTimes(1);
}); });
it("keeps a higher initial debounce threshold in block stream mode", async () => { it("uses immediate preview updates for legacy block stream mode", async () => {
const draftStream = createDraftStream(); const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream); createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation( dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -209,7 +209,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream).toHaveBeenCalledWith( expect(createTelegramDraftStream).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
minInitialChars: 30, minInitialChars: 1,
}), }),
); );
}); });
@@ -445,7 +445,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
); );
}); });
it("forces new message when new assistant message starts after previous output", async () => { it("does not force new message for legacy block stream mode", async () => {
const draftStream = createDraftStream(999); const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream); createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation( dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
@@ -464,8 +464,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createContext(), streamMode: "block" }); await dispatchWithContext({ context: createContext(), streamMode: "block" });
// Should force new message when assistant message starts after previous output expect(draftStream.forceNewMessage).not.toHaveBeenCalled();
expect(draftStream.forceNewMessage).toHaveBeenCalled();
}); });
it("does not force new message in partial mode when assistant message restarts", async () => { it("does not force new message in partial mode when assistant message restarts", async () => {

View File

@@ -6,7 +6,6 @@ import {
modelSupportsVision, modelSupportsVision,
} from "../agents/model-catalog.js"; } from "../agents/model-catalog.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
import { resolveChunkMode } from "../auto-reply/chunk.js"; import { resolveChunkMode } from "../auto-reply/chunk.js";
import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js"; import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
@@ -26,7 +25,6 @@ import type { TelegramBotOptions } from "./bot.js";
import { deliverReplies } from "./bot/delivery.js"; import { deliverReplies } from "./bot/delivery.js";
import type { TelegramStreamMode } from "./bot/types.js"; import type { TelegramStreamMode } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
import { createTelegramDraftStream } from "./draft-stream.js"; import { createTelegramDraftStream } from "./draft-stream.js";
import { renderTelegramHtmlText } from "./format.js"; import { renderTelegramHtmlText } from "./format.js";
import { import {
@@ -143,21 +141,20 @@ export const dispatchTelegramMessage = async ({
}); });
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on"; const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
const streamReasoningDraft = resolvedReasoningLevel === "stream"; const streamReasoningDraft = resolvedReasoningLevel === "stream";
const previewStreamingEnabled = streamMode !== "off";
const canStreamAnswerDraft = const canStreamAnswerDraft =
streamMode !== "off" && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning; previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning;
const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft; const canStreamReasoningDraft = canStreamAnswerDraft || streamReasoningDraft;
const draftReplyToMessageId = const draftReplyToMessageId =
replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined; replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined;
const draftMinInitialChars = const draftMinInitialChars =
streamMode === "partial" || streamReasoningDraft ? 1 : DRAFT_MIN_INITIAL_CHARS; previewStreamingEnabled || streamReasoningDraft ? 1 : DRAFT_MIN_INITIAL_CHARS;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
type LaneName = "answer" | "reasoning"; type LaneName = "answer" | "reasoning";
type DraftLaneState = { type DraftLaneState = {
stream: ReturnType<typeof createTelegramDraftStream> | undefined; stream: ReturnType<typeof createTelegramDraftStream> | undefined;
lastPartialText: string; lastPartialText: string;
draftText: string;
hasStreamedMessage: boolean; hasStreamedMessage: boolean;
chunker: EmbeddedBlockChunker | undefined;
}; };
const createDraftLane = (enabled: boolean): DraftLaneState => { const createDraftLane = (enabled: boolean): DraftLaneState => {
const stream = enabled const stream = enabled
@@ -173,16 +170,10 @@ export const dispatchTelegramMessage = async ({
warn: logVerbose, warn: logVerbose,
}) })
: undefined; : undefined;
const chunker =
stream && streamMode === "block"
? new EmbeddedBlockChunker(resolveTelegramDraftStreamingChunking(cfg, route.accountId))
: undefined;
return { return {
stream, stream,
lastPartialText: "", lastPartialText: "",
draftText: "",
hasStreamedMessage: false, hasStreamedMessage: false,
chunker,
}; };
}; };
const lanes: Record<LaneName, DraftLaneState> = { const lanes: Record<LaneName, DraftLaneState> = {
@@ -207,9 +198,7 @@ export const dispatchTelegramMessage = async ({
}; };
const resetDraftLaneState = (lane: DraftLaneState) => { const resetDraftLaneState = (lane: DraftLaneState) => {
lane.lastPartialText = ""; lane.lastPartialText = "";
lane.draftText = "";
lane.hasStreamedMessage = false; lane.hasStreamedMessage = false;
lane.chunker?.reset();
}; };
const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => { const updateDraftFromPartial = (lane: DraftLaneState, text: string | undefined) => {
const laneStream = lane.stream; const laneStream = lane.stream;
@@ -221,46 +210,18 @@ export const dispatchTelegramMessage = async ({
} }
// Mark that we've received streaming content (for forceNewMessage decision). // Mark that we've received streaming content (for forceNewMessage decision).
lane.hasStreamedMessage = true; lane.hasStreamedMessage = true;
if (streamMode === "partial") { // Some providers briefly emit a shorter prefix snapshot (for example
// Some providers briefly emit a shorter prefix snapshot (for example // "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid
// "Sure." -> "Sure" -> "Sure."). Keep the longer preview to avoid // visible punctuation flicker.
// visible punctuation flicker. if (
if ( lane.lastPartialText &&
lane.lastPartialText && lane.lastPartialText.startsWith(text) &&
lane.lastPartialText.startsWith(text) && text.length < lane.lastPartialText.length
text.length < lane.lastPartialText.length ) {
) {
return;
}
lane.lastPartialText = text;
laneStream.update(text);
return; return;
} }
let delta = text;
if (text.startsWith(lane.lastPartialText)) {
delta = text.slice(lane.lastPartialText.length);
} else {
// Streaming buffer reset (or non-monotonic stream). Start fresh.
lane.chunker?.reset();
lane.draftText = "";
}
lane.lastPartialText = text; lane.lastPartialText = text;
if (!delta) { laneStream.update(text);
return;
}
if (!lane.chunker) {
lane.draftText = text;
laneStream.update(lane.draftText);
return;
}
lane.chunker.append(delta);
lane.chunker.drain({
force: false,
emit: (chunk) => {
lane.draftText += chunk;
laneStream.update(lane.draftText);
},
});
}; };
const ingestDraftLaneSegments = (text: string | undefined) => { const ingestDraftLaneSegments = (text: string | undefined) => {
for (const segment of splitTextIntoLaneSegments(text)) { for (const segment of splitTextIntoLaneSegments(text)) {
@@ -275,31 +236,18 @@ export const dispatchTelegramMessage = async ({
if (!lane.stream) { if (!lane.stream) {
return; return;
} }
if (lane.chunker?.hasBuffered()) {
lane.chunker.drain({
force: true,
emit: (chunk) => {
lane.draftText += chunk;
},
});
lane.chunker.reset();
if (lane.draftText) {
lane.stream.update(lane.draftText);
}
}
await lane.stream.flush(); await lane.stream.flush();
}; };
const disableBlockStreaming = const disableBlockStreaming = !previewStreamingEnabled
streamMode === "off" ? true
? true : forceBlockStreamingForReasoning
: forceBlockStreamingForReasoning ? false
? false : typeof telegramCfg.blockStreaming === "boolean"
: typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming
? !telegramCfg.blockStreaming : canStreamAnswerDraft
: canStreamAnswerDraft ? true
? true : undefined;
: undefined;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg, cfg,
@@ -395,8 +343,7 @@ export const dispatchTelegramMessage = async ({
linkPreview: telegramCfg.linkPreview, linkPreview: telegramCfg.linkPreview,
replyQuoteText, replyQuoteText,
}; };
const getLanePreviewText = (lane: DraftLaneState) => const getLanePreviewText = (lane: DraftLaneState) => lane.lastPartialText;
streamMode === "block" ? lane.draftText : lane.lastPartialText;
const tryUpdatePreviewForLane = async (params: { const tryUpdatePreviewForLane = async (params: {
lane: DraftLaneState; lane: DraftLaneState;
laneName: LaneName; laneName: LaneName;
@@ -449,7 +396,6 @@ export const dispatchTelegramMessage = async ({
}); });
if (updateLaneSnapshot) { if (updateLaneSnapshot) {
lane.lastPartialText = text; lane.lastPartialText = text;
lane.draftText = text;
} }
deliveryState.delivered = true; deliveryState.delivered = true;
return true; return true;
@@ -684,10 +630,6 @@ export const dispatchTelegramMessage = async ({
onAssistantMessageStart: answerLane.stream onAssistantMessageStart: answerLane.stream
? () => { ? () => {
reasoningStepState.resetForNextStep(); reasoningStepState.resetForNextStep();
// Keep answer blocks separated in block mode; partial mode keeps one answer lane.
if (streamMode === "block" && answerLane.hasStreamedMessage) {
answerLane.stream?.forceNewMessage();
}
resetDraftLaneState(answerLane); resetDraftLaneState(answerLane);
} }
: undefined, : undefined,

View File

@@ -154,11 +154,18 @@ export function buildTypingThreadParams(messageThreadId?: number) {
} }
export function resolveTelegramStreamMode(telegramCfg?: { export function resolveTelegramStreamMode(telegramCfg?: {
streaming?: boolean;
streamMode?: TelegramStreamMode; streamMode?: TelegramStreamMode;
}): TelegramStreamMode { }): TelegramStreamMode {
if (typeof telegramCfg?.streaming === "boolean") {
return telegramCfg.streaming ? "partial" : "off";
}
const raw = telegramCfg?.streamMode?.trim().toLowerCase(); const raw = telegramCfg?.streamMode?.trim().toLowerCase();
if (raw === "off" || raw === "partial" || raw === "block") { if (raw === "off") {
return raw; return "off";
}
if (raw === "partial" || raw === "block") {
return "partial";
} }
return "partial"; return "partial";
} }