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.
This commit is contained in:
Peter Steinberger
2026-05-17 04:26:35 +01:00
committed by GitHub
parent 39a9a3478f
commit 549a0ea313
8 changed files with 255 additions and 62 deletions

View File

@@ -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.

View File

@@ -166,12 +166,31 @@ const recordInboundSession = vi.hoisted(() =>
vi.fn<(params?: unknown) => Promise<void>>(async () => {}),
);
const configSessionsMocks = vi.hoisted(() => ({
loadSessionStore: vi.fn<(storePath: string, opts?: unknown) => Record<string, unknown>>(
() => ({}),
),
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<string, unknown>; 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<string, unknown>; 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"));

View File

@@ -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<string | undefined> => {
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,

View File

@@ -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<string> =>
isPotentialTruncatedFinal(text)
? (selectLongerFinalText({
finalText: text,
candidateTexts: [await resolveCurrentTurnTranscriptFinalText()],
}) ?? text)
: text;
await resolveTranscriptBackedChannelFinalText({
finalText: text,
resolveCandidateText: resolveCurrentTurnTranscriptFinalText,
});
if (isDmTopic) {
try {

View File

@@ -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

View File

@@ -1,7 +1,9 @@
export {
createLaneTextDeliverer,
isPotentialTruncatedFinal,
selectLongerFinalText,
} from "openclaw/plugin-sdk/channel-streaming";
export {
createLaneTextDeliverer,
type DraftLaneState,
type LaneDeliveryResult,
type LaneName,

View File

@@ -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({

View File

@@ -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<string | undefined>;
}): Promise<string> {
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";