From 549a0ea313104d213bbe90e924acd6027bbd84c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 04:26:35 +0100 Subject: [PATCH] fix(discord): recover truncated progress finals Summary: - Add shared SDK helpers for transcript-backed recovery of ellipsis-truncated final text. - Use the helper in Discord progress preview delivery so long answers fall through to normal chunked delivery with the full transcript text. - Refactor Telegram to reuse the shared helper. Verification: - node scripts/run-vitest.mjs src/plugin-sdk/channel-streaming.test.ts extensions/discord/src/monitor/message-handler.process.test.ts - pnpm exec oxfmt --check --threads=1 src/plugin-sdk/channel-streaming.ts src/plugin-sdk/channel-streaming.test.ts extensions/telegram/src/lane-delivery-text-deliverer.ts extensions/telegram/src/lane-delivery.ts extensions/telegram/src/bot-message-dispatch.ts extensions/discord/src/monitor/message-handler.process.ts extensions/discord/src/monitor/message-handler.process.test.ts - node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo - git diff --check - pnpm check:changed via Blacksmith Testbox tbx_01krsy80a5qgfw790nm45770xt - GitHub PR checks green on #82862 - codex-review --mode local: clean, no accepted/actionable findings Fixes #82807. --- CHANGELOG.md | 1 + .../monitor/message-handler.process.test.ts | 85 ++++++++++++++++++- .../src/monitor/message-handler.process.ts | 63 ++++++++++++-- .../telegram/src/bot-message-dispatch.ts | 13 ++- .../src/lane-delivery-text-deliverer.ts | 48 +---------- extensions/telegram/src/lane-delivery.ts | 4 +- src/plugin-sdk/channel-streaming.test.ts | 44 ++++++++++ src/plugin-sdk/channel-streaming.ts | 59 +++++++++++++ 8 files changed, 255 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6423ca35a8..e4ca2b0560f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai - Gateway/usage: refresh large session usage summaries in the background and reuse durable transcript metadata so `sessions.usage` no longer blocks Gateway requests on full transcript rescans. Fixes #82773. (#82778) Thanks @hclsys. - TUI: restore the submitted draft when chat is busy instead of clearing it or queueing another run. Fixes #45326. (#82774) Thanks @hyspacex. - Cron/memory: treat claimed `before_agent_reply` cron hooks as execution progress, so long memory dreaming promotion jobs are not aborted by the isolated-run pre-execution watchdog. Fixes #82811. +- Discord: recover transcript-backed full answers when progress-mode final payloads are ellipsis-truncated, so long replies fall back to normal chunked delivery instead of replacing the preview with a shortened message. Fixes #82807. Thanks @blueberry6401. - Browser plugin: redact attach-details from Chrome MCP diagnostics and keep raw Chrome launch error output around long enough to surface in user reports without leaking sensitive paths. - System prompts: clarify MEMORY guidance over generic TTS hints in the embedded speech-core/system-prompt scaffolding so agents prefer memory-store usage over speech defaults. Fixes #81930. Thanks @giodl73-repo. - Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos. diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 2ef3c7756e5..60c278b1fe1 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -166,12 +166,31 @@ const recordInboundSession = vi.hoisted(() => vi.fn<(params?: unknown) => Promise>(async () => {}), ); const configSessionsMocks = vi.hoisted(() => ({ + loadSessionStore: vi.fn<(storePath: string, opts?: unknown) => Record>( + () => ({}), + ), readSessionUpdatedAt: vi.fn<(params?: unknown) => number | undefined>(() => undefined), + readLatestAssistantTextFromSessionTranscript: vi.fn< + (sessionFile: string) => Promise<{ text: string; timestamp?: number } | undefined> + >(async () => undefined), + resolveAndPersistSessionFile: vi.fn<(params?: unknown) => Promise<{ sessionFile: string }>>( + async () => ({ sessionFile: "/tmp/openclaw-discord-process-test-session.jsonl" }), + ), + resolveSessionStoreEntry: vi.fn< + (params: { store: Record; sessionKey?: string }) => { existing?: unknown } + >((params) => ({ + existing: params.sessionKey ? params.store[params.sessionKey] : undefined, + })), resolveStorePath: vi.fn<(path?: unknown, opts?: unknown) => string>( () => "/tmp/openclaw-discord-process-test-sessions.json", ), })); +const loadSessionStore = configSessionsMocks.loadSessionStore; const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt; +const readLatestAssistantTextFromSessionTranscript = + configSessionsMocks.readLatestAssistantTextFromSessionTranscript; +const resolveAndPersistSessionFile = configSessionsMocks.resolveAndPersistSessionFile; +const resolveSessionStoreEntry = configSessionsMocks.resolveSessionStoreEntry; const resolveStorePath = configSessionsMocks.resolveStorePath; const createDiscordRestClientSpy = vi.hoisted(() => vi.fn< @@ -266,8 +285,17 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ })); vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({ - readSessionUpdatedAt: (...args: unknown[]) => configSessionsMocks.readSessionUpdatedAt(...args), - resolveStorePath: (...args: unknown[]) => configSessionsMocks.resolveStorePath(...args), + loadSessionStore: (storePath: string, opts?: unknown) => + configSessionsMocks.loadSessionStore(storePath, opts), + readSessionUpdatedAt: (params?: unknown) => configSessionsMocks.readSessionUpdatedAt(params), + readLatestAssistantTextFromSessionTranscript: (sessionFile: string) => + configSessionsMocks.readLatestAssistantTextFromSessionTranscript(sessionFile), + resolveAndPersistSessionFile: (params?: unknown) => + configSessionsMocks.resolveAndPersistSessionFile(params), + resolveSessionStoreEntry: (params: { store: Record; sessionKey?: string }) => + configSessionsMocks.resolveSessionStoreEntry(params), + resolveStorePath: (path?: unknown, opts?: unknown) => + configSessionsMocks.resolveStorePath(path, opts), })); vi.mock("../client.js", () => ({ @@ -358,12 +386,24 @@ beforeEach(() => { createDiscordDraftStream.mockClear(); dispatchInboundMessage.mockClear(); recordInboundSession.mockClear(); + loadSessionStore.mockClear(); readSessionUpdatedAt.mockClear(); + readLatestAssistantTextFromSessionTranscript.mockClear(); + resolveAndPersistSessionFile.mockClear(); + resolveSessionStoreEntry.mockClear(); resolveStorePath.mockClear(); createDiscordRestClientSpy.mockClear(); dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult()); recordInboundSession.mockResolvedValue(undefined); + loadSessionStore.mockReturnValue({}); readSessionUpdatedAt.mockReturnValue(undefined); + readLatestAssistantTextFromSessionTranscript.mockResolvedValue(undefined); + resolveAndPersistSessionFile.mockResolvedValue({ + sessionFile: "/tmp/openclaw-discord-process-test-session.jsonl", + }); + resolveSessionStoreEntry.mockImplementation((params) => ({ + existing: params.sessionKey ? params.store[params.sessionKey] : undefined, + })); resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json"); threadBindingTesting.resetThreadBindingsForTests(); }); @@ -1739,6 +1779,47 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("uses transcript-backed final text when progress final text is truncated", async () => { + const draftStream = createMockDraftStreamForTest(); + const prefix = + "Here is the complete Discord answer with enough stable prefix text before truncation"; + const truncatedFinal = `${prefix}...`; + const fullAnswer = `${prefix} ${Array.from( + { length: 260 }, + (_value, index) => `continuation${index}`, + ).join(" ")}`; + + loadSessionStore.mockReturnValue({ + "agent:main:discord:channel:c1": { sessionId: "session-1" }, + }); + readLatestAssistantTextFromSessionTranscript.mockResolvedValue({ + text: fullAnswer, + timestamp: Date.now() + 60_000, + }); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" }); + await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" }); + await params?.dispatcher.sendFinalReply({ text: truncatedFinal }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createAutomaticSourceDeliveryContext({ + baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey, + discordConfig: { maxLinesPerMessage: 120 }, + route: BASE_CHANNEL_ROUTE, + }); + + await runProcessDiscordMessage(ctx); + + expect(draftStream.update).toHaveBeenCalledTimes(1); + expect(editMessageDiscord).not.toHaveBeenCalled(); + expect(deliverDiscordReply).toHaveBeenCalledTimes(1); + const params = firstMockArg(deliverDiscordReply, "deliverDiscordReply"); + const replies = requireRecord(params, "deliverDiscordReply params").replies; + expect(Array.isArray(replies)).toBe(true); + expect((replies as Array<{ text?: string }>)[0]?.text).toBe(fullAnswer); + }); + it("clears partial drafts when fallback final delivery fails before completion", async () => { const draftStream = createMockDraftStreamForTest(); deliverDiscordReply.mockRejectedValueOnce(new Error("send failed")); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 701b0e60a5c..295401e7675 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { MessageFlags } from "discord-api-types/v10"; import { formatReasoningMessage, @@ -21,6 +22,7 @@ import { buildChannelProgressDraftLine, buildChannelProgressDraftLineForEntry, resolveChannelStreamingBlockEnabled, + resolveTranscriptBackedChannelFinalText, } from "openclaw/plugin-sdk/channel-streaming"; import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime"; import { @@ -35,6 +37,13 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime"; import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + loadSessionStore, + readLatestAssistantTextFromSessionTranscript, + resolveAndPersistSessionFile, + resolveSessionStoreEntry, + resolveStorePath, +} from "openclaw/plugin-sdk/session-store-runtime"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { createDiscordRestClient } from "../client.js"; import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js"; @@ -117,6 +126,7 @@ export async function processDiscordMessage( ctx: DiscordMessagePreflightContext, observer?: DiscordMessageProcessObserver, ) { + const dispatchStartedAt = Date.now(); const { cfg, discordConfig, @@ -447,6 +457,37 @@ export async function processDiscordMessage( ) : () => {}; const endDiscordInboundEventDeliveryCorrelation = beginDeliveryCorrelation(); + const resolveCurrentTurnTranscriptFinalText = async (): Promise => { + const sessionKey = ctxPayload.SessionKey; + if (!sessionKey) { + return undefined; + } + try { + const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId }); + const store = loadSessionStore(storePath, { clone: false }); + const sessionEntry = resolveSessionStoreEntry({ store, sessionKey }).existing; + if (!sessionEntry?.sessionId) { + return undefined; + } + const { sessionFile } = await resolveAndPersistSessionFile({ + sessionId: sessionEntry.sessionId, + sessionKey, + sessionStore: store, + storePath, + sessionEntry, + agentId: route.agentId, + sessionsDir: path.dirname(storePath), + }); + const latest = await readLatestAssistantTextFromSessionTranscript(sessionFile); + if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) { + return undefined; + } + return latest.text; + } catch (err) { + logVerbose(`discord transcript final candidate lookup failed: ${String(err)}`); + return undefined; + } + }; const deliverChannelId = deliverTarget.startsWith("channel:") ? deliverTarget.slice("channel:".length) @@ -489,9 +530,18 @@ export async function processDiscordMessage( // Reasoning/thinking payloads should not be delivered to Discord. return; } + const finalText = + isFinal && typeof payload.text === "string" + ? await resolveTranscriptBackedChannelFinalText({ + finalText: payload.text, + resolveCandidateText: resolveCurrentTurnTranscriptFinalText, + }) + : payload.text; + const effectivePayload = + finalText !== payload.text ? { ...payload, text: finalText } : payload; const draftStream = draftPreview.draftStream; if (draftStream && draftPreview.isProgressMode && info.kind === "block") { - const reply = resolveSendableOutboundReplyParts(payload); + const reply = resolveSendableOutboundReplyParts(effectivePayload); if (!reply.hasMedia && !payload.isError) { return; } @@ -501,17 +551,16 @@ export async function processDiscordMessage( isFinal && (!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) ) { - const reply = resolveSendableOutboundReplyParts(payload); + const reply = resolveSendableOutboundReplyParts(effectivePayload); const hasMedia = reply.hasMedia; - const finalText = payload.text; const previewFinalText = draftPreview.resolvePreviewFinalText(finalText); const hasExplicitReplyDirective = - Boolean(payload.replyToTag || payload.replyToCurrent) || + Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) || (typeof finalText === "string" && /\[\[\s*reply_to(?:_current|\s*:)/i.test(finalText)); const result = await deliverWithFinalizableLivePreviewAdapter({ kind: info.kind, - payload, + payload: effectivePayload, adapter: defineFinalizableLivePreviewAdapter({ draft: { flush: () => draftPreview.flush(), @@ -566,7 +615,7 @@ export async function processDiscordMessage( notifyFinalReplyStart(); await deliverDiscordReply({ cfg, - replies: [payload], + replies: [effectivePayload], target: deliverTarget, token, accountId, @@ -604,7 +653,7 @@ export async function processDiscordMessage( } await deliverDiscordReply({ cfg, - replies: [payload], + replies: [effectivePayload], target: deliverTarget, token, accountId, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 966efcba56f..5aefbecd849 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -26,6 +26,7 @@ import { resolveChannelProgressDraftMaxLines, resolveChannelStreamingBlockEnabled, resolveChannelStreamingPreviewToolProgress, + resolveTranscriptBackedChannelFinalText, } from "openclaw/plugin-sdk/channel-streaming"; import { isAbortRequestText } from "openclaw/plugin-sdk/command-primitives-runtime"; import type { @@ -100,8 +101,6 @@ import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-de import { createLaneDeliveryStateTracker, createLaneTextDeliverer, - isPotentialTruncatedFinal, - selectLongerFinalText, type DraftLaneState, type LaneDeliveryResult, type LaneName, @@ -1283,12 +1282,10 @@ export const dispatchTelegramMessage = async ({ return delivered ? { kind: "sent" } : { kind: "skipped" }; }; const resolveTranscriptBackedFinalText = async (text: string): Promise => - isPotentialTruncatedFinal(text) - ? (selectLongerFinalText({ - finalText: text, - candidateTexts: [await resolveCurrentTurnTranscriptFinalText()], - }) ?? text) - : text; + await resolveTranscriptBackedChannelFinalText({ + finalText: text, + resolveCandidateText: resolveCurrentTurnTranscriptFinalText, + }); if (isDmTopic) { try { diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index a388d895898..ecb93d88958 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -2,6 +2,10 @@ import { createPreviewMessageReceipt, type MessageReceipt, } from "openclaw/plugin-sdk/channel-message"; +import { + isPotentialTruncatedFinal, + selectLongerFinalText, +} from "openclaw/plugin-sdk/channel-streaming"; import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -106,50 +110,6 @@ function compactChunks(chunks: readonly string[]): string[] { return out; } -function stripTrailingEllipsis(text: string): string { - return text.replace(/(?:\s*(?:\.{3}|\u2026))+$/u, "").trimEnd(); -} - -const MIN_TRUNCATED_FINAL_PREFIX_CHARS = 48; -const MIN_TRUNCATED_FINAL_CONTINUATION_CHARS = 24; - -export function isPotentialTruncatedFinal(finalText: string): boolean { - const trimmedFinal = finalText.trimEnd(); - const untruncatedFinal = stripTrailingEllipsis(trimmedFinal); - return ( - untruncatedFinal.length >= MIN_TRUNCATED_FINAL_PREFIX_CHARS && untruncatedFinal !== trimmedFinal - ); -} - -export function selectLongerFinalText(params: { - finalText: string; - candidateTexts: readonly (string | undefined)[]; -}): string | undefined { - const finalText = params.finalText.trimEnd(); - if (!isPotentialTruncatedFinal(finalText)) { - return undefined; - } - const untruncatedFinal = stripTrailingEllipsis(finalText); - for (const candidate of params.candidateTexts) { - const candidateText = candidate?.trimEnd(); - if ( - !candidateText || - candidateText.length <= finalText.length || - !candidateText.startsWith(untruncatedFinal) - ) { - continue; - } - const continuation = candidateText.slice(untruncatedFinal.length).trimStart(); - if ( - continuation.length >= MIN_TRUNCATED_FINAL_CONTINUATION_CHARS && - /^[\p{L}\p{N}]/u.test(continuation) - ) { - return candidateText; - } - } - return undefined; -} - export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { const followUpPayload = (payload: ReplyPayload, text: string) => params.applyTextToFollowUpPayload diff --git a/extensions/telegram/src/lane-delivery.ts b/extensions/telegram/src/lane-delivery.ts index 615b91f8c05..869c3851822 100644 --- a/extensions/telegram/src/lane-delivery.ts +++ b/extensions/telegram/src/lane-delivery.ts @@ -1,7 +1,9 @@ export { - createLaneTextDeliverer, isPotentialTruncatedFinal, selectLongerFinalText, +} from "openclaw/plugin-sdk/channel-streaming"; +export { + createLaneTextDeliverer, type DraftLaneState, type LaneDeliveryResult, type LaneName, diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts index 020b03caff5..bdf0d86c460 100644 --- a/src/plugin-sdk/channel-streaming.test.ts +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -8,6 +8,7 @@ import { formatChannelProgressDraftText, getChannelStreamingConfigObject, isChannelProgressDraftWorkToolName, + isPotentialTruncatedFinal, mergeChannelProgressDraftLine, resolveChannelPreviewStreamMode, resolveChannelProgressDraftLabel, @@ -21,6 +22,8 @@ import { resolveChannelStreamingPreviewChunk, resolveChannelStreamingSuppressDefaultToolProgressMessages, resolveChannelStreamingPreviewToolProgress, + resolveTranscriptBackedChannelFinalText, + selectLongerFinalText, } from "./channel-streaming.js"; describe("channel-streaming", () => { @@ -140,6 +143,47 @@ describe("channel-streaming", () => { ); }); + it("selects a longer transcript candidate for ellipsis-truncated finals", async () => { + const fullAnswer = + "Here is the complete final answer with enough stable prefix text before the ellipsis and enough continuation text after it."; + const truncatedFinal = + "Here is the complete final answer with enough stable prefix text before the ellipsis..."; + + expect(isPotentialTruncatedFinal(truncatedFinal)).toBe(true); + expect( + selectLongerFinalText({ + finalText: truncatedFinal, + candidateTexts: ["short", fullAnswer], + }), + ).toBe(fullAnswer); + await expect( + resolveTranscriptBackedChannelFinalText({ + finalText: truncatedFinal, + resolveCandidateText: async () => fullAnswer, + }), + ).resolves.toBe(fullAnswer); + }); + + it("keeps intentional ellipsis finals when candidates do not prove truncation", async () => { + const finalText = + "Here is the complete final answer with enough stable prefix text before an intentional pause..."; + const candidateText = + "Here is the complete final answer with enough stable prefix text before an intentional pause... then punctuation"; + + expect( + selectLongerFinalText({ + finalText, + candidateTexts: [candidateText], + }), + ).toBeUndefined(); + await expect( + resolveTranscriptBackedChannelFinalText({ + finalText, + resolveCandidateText: async () => candidateText, + }), + ).resolves.toBe(finalText); + }); + it("suppresses standalone tool progress for active preview drafts", () => { expect( resolveChannelStreamingSuppressDefaultToolProgressMessages({ diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index 69e550bcfd4..e5c5a56a16d 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -114,6 +114,8 @@ export const DEFAULT_PROGRESS_DRAFT_LABELS = [ export const DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS = 5_000; const DEFAULT_PROGRESS_DRAFT_MAX_LINE_CHARS = 72; +const MIN_TRUNCATED_FINAL_PREFIX_CHARS = 48; +const MIN_TRUNCATED_FINAL_CONTINUATION_CHARS = 24; const NON_WORK_PROGRESS_TOOL_NAMES = new Set([ "message", @@ -130,6 +132,63 @@ export function isChannelProgressDraftWorkToolName(name: string | null | undefin return Boolean(normalized && !NON_WORK_PROGRESS_TOOL_NAMES.has(normalized)); } +function stripTrailingEllipsis(text: string): string { + return text.replace(/(?:\s*(?:\.{3}|\u2026))+$/u, "").trimEnd(); +} + +export function isPotentialTruncatedFinal(finalText: string): boolean { + const trimmedFinal = finalText.trimEnd(); + const untruncatedFinal = stripTrailingEllipsis(trimmedFinal); + return ( + untruncatedFinal.length >= MIN_TRUNCATED_FINAL_PREFIX_CHARS && untruncatedFinal !== trimmedFinal + ); +} + +export function selectLongerFinalText(params: { + finalText: string; + candidateTexts: readonly (string | undefined)[]; +}): string | undefined { + const finalText = params.finalText.trimEnd(); + if (!isPotentialTruncatedFinal(finalText)) { + return undefined; + } + const untruncatedFinal = stripTrailingEllipsis(finalText); + for (const candidate of params.candidateTexts) { + const candidateText = candidate?.trimEnd(); + if ( + !candidateText || + candidateText.length <= finalText.length || + !candidateText.startsWith(untruncatedFinal) + ) { + continue; + } + const continuation = candidateText.slice(untruncatedFinal.length).trimStart(); + if ( + continuation.length >= MIN_TRUNCATED_FINAL_CONTINUATION_CHARS && + /^[\p{L}\p{N}]/u.test(continuation) + ) { + return candidateText; + } + } + return undefined; +} + +export async function resolveTranscriptBackedChannelFinalText(params: { + finalText: string; + resolveCandidateText: () => Promise; +}): Promise { + if (!isPotentialTruncatedFinal(params.finalText)) { + return params.finalText; + } + const candidateText = await params.resolveCandidateText(); + return ( + selectLongerFinalText({ + finalText: params.finalText, + candidateTexts: [candidateText], + }) ?? params.finalText + ); +} + export type ChannelProgressLineOptions = { markdown?: boolean; detailMode?: "explain" | "raw";