diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b726dfef..8fbddabe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. - Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. - Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reasoning: when `/reasoning off` is active, suppress reasoning-only delivery segments and block raw fallback resend of suppressed `Reasoning:`/`` text, preventing internal reasoning leakage in legacy sessions while preserving answer delivery. (#24626, #24518) - Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. - Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. - Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 5e080e90e..75a8fb6b9 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -176,6 +176,15 @@ describe("dispatchTelegramMessage draft streaming", () => { }); } + function createReasoningStreamContext(): TelegramMessageContext { + loadSessionStore.mockReturnValue({ + s1: { reasoningLevel: "stream" }, + }); + return createContext({ + ctxPayload: { SessionKey: "s1" } as unknown as TelegramMessageContext["ctxPayload"], + }); + } + it("streams drafts in private threads and forwards thread id", async () => { const draftStream = createDraftStream(); createTelegramDraftStream.mockReturnValue(draftStream); @@ -772,7 +781,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode }); expect(reasoningDraftStream.forceNewMessage).toHaveBeenCalledTimes(1); }, @@ -809,7 +818,11 @@ describe("dispatchTelegramMessage draft streaming", () => { editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); const bot = createBot(); - await dispatchWithContext({ context: createContext(), streamMode: "partial", bot }); + await dispatchWithContext({ + context: createReasoningStreamContext(), + streamMode: "partial", + bot, + }); expect(reasoningDraftParams?.onSupersededPreview).toBeTypeOf("function"); const deleteMessageCalls = ( @@ -836,13 +849,13 @@ describe("dispatchTelegramMessage draft streaming", () => { ); deliverReplies.mockResolvedValue({ delivered: true }); - await dispatchWithContext({ context: createContext(), streamMode }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode }); expect(reasoningDraftStream.forceNewMessage).not.toHaveBeenCalled(); }, ); - it("does not finalize preview with reasoning payloads before answer payloads", async () => { + it("suppresses reasoning-only final payloads when reasoning level is off", async () => { setupDraftStreams({ answerMessageId: 999 }); dispatchReplyWithBufferedBlockDispatcher.mockImplementation( async ({ dispatcherOptions, replyOptions }) => { @@ -860,14 +873,11 @@ describe("dispatchTelegramMessage draft streaming", () => { await dispatchWithContext({ context: createContext(), streamMode: "partial" }); - // Keep reasoning as its own message. - expect(deliverReplies).toHaveBeenCalledTimes(1); - expect(deliverReplies).toHaveBeenCalledWith( + expect(deliverReplies).not.toHaveBeenCalledWith( expect.objectContaining({ replies: [expect.objectContaining({ text: "Reasoning:\n_step one_" })], }), ); - // Finalize preview with the actual answer instead of overwriting with reasoning. expect(editMessageTelegram).toHaveBeenCalledTimes(1); expect(editMessageTelegram).toHaveBeenCalledWith( 123, @@ -877,6 +887,25 @@ describe("dispatchTelegramMessage draft streaming", () => { ); }); + it("does not resend suppressed reasoning-only text through raw fallback", async () => { + setupDraftStreams({ answerMessageId: 999 }); + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Reasoning:\n_step one_" }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); + + await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + + expect(deliverReplies).not.toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "Reasoning:\n_step one_" })], + }), + ); + expect(editMessageTelegram).not.toHaveBeenCalled(); + }); + it("keeps reasoning and answer streaming in separate preview lanes", async () => { const { answerDraftStream, reasoningDraftStream } = setupDraftStreams({ answerMessageId: 999, @@ -893,7 +922,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(reasoningDraftStream.update).toHaveBeenCalledWith("Reasoning:\n_Working on it..._"); expect(answerDraftStream.update).toHaveBeenCalledWith("Checking the directory..."); @@ -913,7 +942,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(editMessageTelegram).not.toHaveBeenCalled(); expect(deliverReplies).toHaveBeenCalledWith( @@ -955,7 +984,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "111" }); - await dispatchWithContext({ context: createContext(), streamMode }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode }); expect(reasoningDraftStream.forceNewMessage).not.toHaveBeenCalled(); expect(editMessageTelegram).toHaveBeenCalledWith( @@ -990,7 +1019,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(editMessageTelegram).toHaveBeenNthCalledWith(1, 123, 999, "3", expect.any(Object)); expect(editMessageTelegram).toHaveBeenNthCalledWith( @@ -1028,7 +1057,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(reasoningDraftStream.update).toHaveBeenCalledWith( "Reasoning:\n_Counting letters in strawberry_", @@ -1060,7 +1089,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(reasoningDraftStream.update).toHaveBeenCalledWith( "Reasoning:\n_Counting letters in strawberry_", @@ -1096,7 +1125,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(reasoningDraftStream.update).toHaveBeenCalledWith( "Reasoning:\n_Word: strawberry. r appears at 3, 8, 9._", @@ -1127,7 +1156,7 @@ describe("dispatchTelegramMessage draft streaming", () => { deliverReplies.mockResolvedValue({ delivered: true }); editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" }); - await dispatchWithContext({ context: createContext(), streamMode: "partial" }); + await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" }); expect(editMessageTelegram).toHaveBeenNthCalledWith( 1, diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 443555fdd..7dd0c4845 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -202,16 +202,25 @@ export const dispatchTelegramMessage = async ({ let splitReasoningOnNextStream = false; const reasoningStepState = createTelegramReasoningStepState(); type SplitLaneSegment = { lane: LaneName; text: string }; - const splitTextIntoLaneSegments = (text?: string): SplitLaneSegment[] => { + type SplitLaneSegmentsResult = { + segments: SplitLaneSegment[]; + suppressedReasoningOnly: boolean; + }; + const splitTextIntoLaneSegments = (text?: string): SplitLaneSegmentsResult => { const split = splitTelegramReasoningText(text); const segments: SplitLaneSegment[] = []; - if (split.reasoningText) { + const suppressReasoning = resolvedReasoningLevel === "off"; + if (split.reasoningText && !suppressReasoning) { segments.push({ lane: "reasoning", text: split.reasoningText }); } if (split.answerText) { segments.push({ lane: "answer", text: split.answerText }); } - return segments; + return { + segments, + suppressedReasoningOnly: + Boolean(split.reasoningText) && suppressReasoning && !split.answerText, + }; }; const resetDraftLaneState = (lane: DraftLaneState) => { lane.lastPartialText = ""; @@ -241,7 +250,8 @@ export const dispatchTelegramMessage = async ({ laneStream.update(text); }; const ingestDraftLaneSegments = (text: string | undefined) => { - for (const segment of splitTextIntoLaneSegments(text)) { + const split = splitTextIntoLaneSegments(text); + for (const segment of split.segments) { if (segment.lane === "reasoning") { reasoningStepState.noteReasoningHint(); reasoningStepState.noteReasoningDelivered(); @@ -418,7 +428,8 @@ export const dispatchTelegramMessage = async ({ const previewButtons = ( payload.channelData?.telegram as { buttons?: TelegramInlineButtons } | undefined )?.buttons; - const segments = splitTextIntoLaneSegments(payload.text); + const split = splitTextIntoLaneSegments(payload.text); + const segments = split.segments; const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; const flushBufferedFinalAnswer = async () => { @@ -478,6 +489,17 @@ export const dispatchTelegramMessage = async ({ if (segments.length > 0) { return; } + if (split.suppressedReasoningOnly) { + if (hasMedia) { + const payloadWithoutSuppressedReasoning = + typeof payload.text === "string" ? { ...payload, text: "" } : payload; + await sendPayload(payloadWithoutSuppressedReasoning); + } + if (info.kind === "final") { + await flushBufferedFinalAnswer(); + } + return; + } if (info.kind === "final") { await answerLane.stream?.stop();