Skip to content

Commit 549a0ea

Browse files
authored
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.
1 parent 39a9a34 commit 549a0ea

8 files changed

Lines changed: 255 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
4545
- 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.
4646
- TUI: restore the submitted draft when chat is busy instead of clearing it or queueing another run. Fixes #45326. (#82774) Thanks @hyspacex.
4747
- 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.
48+
- 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.
4849
- 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.
4950
- 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.
5051
- 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.

extensions/discord/src/monitor/message-handler.process.test.ts

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,31 @@ const recordInboundSession = vi.hoisted(() =>
166166
vi.fn<(params?: unknown) => Promise<void>>(async () => {}),
167167
);
168168
const configSessionsMocks = vi.hoisted(() => ({
169+
loadSessionStore: vi.fn<(storePath: string, opts?: unknown) => Record<string, unknown>>(
170+
() => ({}),
171+
),
169172
readSessionUpdatedAt: vi.fn<(params?: unknown) => number | undefined>(() => undefined),
173+
readLatestAssistantTextFromSessionTranscript: vi.fn<
174+
(sessionFile: string) => Promise<{ text: string; timestamp?: number } | undefined>
175+
>(async () => undefined),
176+
resolveAndPersistSessionFile: vi.fn<(params?: unknown) => Promise<{ sessionFile: string }>>(
177+
async () => ({ sessionFile: "/tmp/openclaw-discord-process-test-session.jsonl" }),
178+
),
179+
resolveSessionStoreEntry: vi.fn<
180+
(params: { store: Record<string, unknown>; sessionKey?: string }) => { existing?: unknown }
181+
>((params) => ({
182+
existing: params.sessionKey ? params.store[params.sessionKey] : undefined,
183+
})),
170184
resolveStorePath: vi.fn<(path?: unknown, opts?: unknown) => string>(
171185
() => "/tmp/openclaw-discord-process-test-sessions.json",
172186
),
173187
}));
188+
const loadSessionStore = configSessionsMocks.loadSessionStore;
174189
const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt;
190+
const readLatestAssistantTextFromSessionTranscript =
191+
configSessionsMocks.readLatestAssistantTextFromSessionTranscript;
192+
const resolveAndPersistSessionFile = configSessionsMocks.resolveAndPersistSessionFile;
193+
const resolveSessionStoreEntry = configSessionsMocks.resolveSessionStoreEntry;
175194
const resolveStorePath = configSessionsMocks.resolveStorePath;
176195
const createDiscordRestClientSpy = vi.hoisted(() =>
177196
vi.fn<
@@ -266,8 +285,17 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
266285
}));
267286

268287
vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({
269-
readSessionUpdatedAt: (...args: unknown[]) => configSessionsMocks.readSessionUpdatedAt(...args),
270-
resolveStorePath: (...args: unknown[]) => configSessionsMocks.resolveStorePath(...args),
288+
loadSessionStore: (storePath: string, opts?: unknown) =>
289+
configSessionsMocks.loadSessionStore(storePath, opts),
290+
readSessionUpdatedAt: (params?: unknown) => configSessionsMocks.readSessionUpdatedAt(params),
291+
readLatestAssistantTextFromSessionTranscript: (sessionFile: string) =>
292+
configSessionsMocks.readLatestAssistantTextFromSessionTranscript(sessionFile),
293+
resolveAndPersistSessionFile: (params?: unknown) =>
294+
configSessionsMocks.resolveAndPersistSessionFile(params),
295+
resolveSessionStoreEntry: (params: { store: Record<string, unknown>; sessionKey?: string }) =>
296+
configSessionsMocks.resolveSessionStoreEntry(params),
297+
resolveStorePath: (path?: unknown, opts?: unknown) =>
298+
configSessionsMocks.resolveStorePath(path, opts),
271299
}));
272300

273301
vi.mock("../client.js", () => ({
@@ -358,12 +386,24 @@ beforeEach(() => {
358386
createDiscordDraftStream.mockClear();
359387
dispatchInboundMessage.mockClear();
360388
recordInboundSession.mockClear();
389+
loadSessionStore.mockClear();
361390
readSessionUpdatedAt.mockClear();
391+
readLatestAssistantTextFromSessionTranscript.mockClear();
392+
resolveAndPersistSessionFile.mockClear();
393+
resolveSessionStoreEntry.mockClear();
362394
resolveStorePath.mockClear();
363395
createDiscordRestClientSpy.mockClear();
364396
dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult());
365397
recordInboundSession.mockResolvedValue(undefined);
398+
loadSessionStore.mockReturnValue({});
366399
readSessionUpdatedAt.mockReturnValue(undefined);
400+
readLatestAssistantTextFromSessionTranscript.mockResolvedValue(undefined);
401+
resolveAndPersistSessionFile.mockResolvedValue({
402+
sessionFile: "/tmp/openclaw-discord-process-test-session.jsonl",
403+
});
404+
resolveSessionStoreEntry.mockImplementation((params) => ({
405+
existing: params.sessionKey ? params.store[params.sessionKey] : undefined,
406+
}));
367407
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
368408
threadBindingTesting.resetThreadBindingsForTests();
369409
});
@@ -1739,6 +1779,47 @@ describe("processDiscordMessage draft streaming", () => {
17391779
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
17401780
});
17411781

1782+
it("uses transcript-backed final text when progress final text is truncated", async () => {
1783+
const draftStream = createMockDraftStreamForTest();
1784+
const prefix =
1785+
"Here is the complete Discord answer with enough stable prefix text before truncation";
1786+
const truncatedFinal = `${prefix}...`;
1787+
const fullAnswer = `${prefix} ${Array.from(
1788+
{ length: 260 },
1789+
(_value, index) => `continuation${index}`,
1790+
).join(" ")}`;
1791+
1792+
loadSessionStore.mockReturnValue({
1793+
"agent:main:discord:channel:c1": { sessionId: "session-1" },
1794+
});
1795+
readLatestAssistantTextFromSessionTranscript.mockResolvedValue({
1796+
text: fullAnswer,
1797+
timestamp: Date.now() + 60_000,
1798+
});
1799+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
1800+
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
1801+
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
1802+
await params?.dispatcher.sendFinalReply({ text: truncatedFinal });
1803+
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
1804+
});
1805+
1806+
const ctx = await createAutomaticSourceDeliveryContext({
1807+
baseSessionKey: BASE_CHANNEL_ROUTE.sessionKey,
1808+
discordConfig: { maxLinesPerMessage: 120 },
1809+
route: BASE_CHANNEL_ROUTE,
1810+
});
1811+
1812+
await runProcessDiscordMessage(ctx);
1813+
1814+
expect(draftStream.update).toHaveBeenCalledTimes(1);
1815+
expect(editMessageDiscord).not.toHaveBeenCalled();
1816+
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
1817+
const params = firstMockArg(deliverDiscordReply, "deliverDiscordReply");
1818+
const replies = requireRecord(params, "deliverDiscordReply params").replies;
1819+
expect(Array.isArray(replies)).toBe(true);
1820+
expect((replies as Array<{ text?: string }>)[0]?.text).toBe(fullAnswer);
1821+
});
1822+
17421823
it("clears partial drafts when fallback final delivery fails before completion", async () => {
17431824
const draftStream = createMockDraftStreamForTest();
17441825
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));

extensions/discord/src/monitor/message-handler.process.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from "node:path";
12
import { MessageFlags } from "discord-api-types/v10";
23
import {
34
formatReasoningMessage,
@@ -21,6 +22,7 @@ import {
2122
buildChannelProgressDraftLine,
2223
buildChannelProgressDraftLineForEntry,
2324
resolveChannelStreamingBlockEnabled,
25+
resolveTranscriptBackedChannelFinalText,
2426
} from "openclaw/plugin-sdk/channel-streaming";
2527
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
2628
import {
@@ -35,6 +37,13 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
3537
import { createChannelHistoryWindow } from "openclaw/plugin-sdk/reply-history";
3638
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
3739
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
40+
import {
41+
loadSessionStore,
42+
readLatestAssistantTextFromSessionTranscript,
43+
resolveAndPersistSessionFile,
44+
resolveSessionStoreEntry,
45+
resolveStorePath,
46+
} from "openclaw/plugin-sdk/session-store-runtime";
3847
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
3948
import { createDiscordRestClient } from "../client.js";
4049
import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js";
@@ -117,6 +126,7 @@ export async function processDiscordMessage(
117126
ctx: DiscordMessagePreflightContext,
118127
observer?: DiscordMessageProcessObserver,
119128
) {
129+
const dispatchStartedAt = Date.now();
120130
const {
121131
cfg,
122132
discordConfig,
@@ -447,6 +457,37 @@ export async function processDiscordMessage(
447457
)
448458
: () => {};
449459
const endDiscordInboundEventDeliveryCorrelation = beginDeliveryCorrelation();
460+
const resolveCurrentTurnTranscriptFinalText = async (): Promise<string | undefined> => {
461+
const sessionKey = ctxPayload.SessionKey;
462+
if (!sessionKey) {
463+
return undefined;
464+
}
465+
try {
466+
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
467+
const store = loadSessionStore(storePath, { clone: false });
468+
const sessionEntry = resolveSessionStoreEntry({ store, sessionKey }).existing;
469+
if (!sessionEntry?.sessionId) {
470+
return undefined;
471+
}
472+
const { sessionFile } = await resolveAndPersistSessionFile({
473+
sessionId: sessionEntry.sessionId,
474+
sessionKey,
475+
sessionStore: store,
476+
storePath,
477+
sessionEntry,
478+
agentId: route.agentId,
479+
sessionsDir: path.dirname(storePath),
480+
});
481+
const latest = await readLatestAssistantTextFromSessionTranscript(sessionFile);
482+
if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) {
483+
return undefined;
484+
}
485+
return latest.text;
486+
} catch (err) {
487+
logVerbose(`discord transcript final candidate lookup failed: ${String(err)}`);
488+
return undefined;
489+
}
490+
};
450491

451492
const deliverChannelId = deliverTarget.startsWith("channel:")
452493
? deliverTarget.slice("channel:".length)
@@ -489,9 +530,18 @@ export async function processDiscordMessage(
489530
// Reasoning/thinking payloads should not be delivered to Discord.
490531
return;
491532
}
533+
const finalText =
534+
isFinal && typeof payload.text === "string"
535+
? await resolveTranscriptBackedChannelFinalText({
536+
finalText: payload.text,
537+
resolveCandidateText: resolveCurrentTurnTranscriptFinalText,
538+
})
539+
: payload.text;
540+
const effectivePayload =
541+
finalText !== payload.text ? { ...payload, text: finalText } : payload;
492542
const draftStream = draftPreview.draftStream;
493543
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
494-
const reply = resolveSendableOutboundReplyParts(payload);
544+
const reply = resolveSendableOutboundReplyParts(effectivePayload);
495545
if (!reply.hasMedia && !payload.isError) {
496546
return;
497547
}
@@ -501,17 +551,16 @@ export async function processDiscordMessage(
501551
isFinal &&
502552
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted)
503553
) {
504-
const reply = resolveSendableOutboundReplyParts(payload);
554+
const reply = resolveSendableOutboundReplyParts(effectivePayload);
505555
const hasMedia = reply.hasMedia;
506-
const finalText = payload.text;
507556
const previewFinalText = draftPreview.resolvePreviewFinalText(finalText);
508557
const hasExplicitReplyDirective =
509-
Boolean(payload.replyToTag || payload.replyToCurrent) ||
558+
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
510559
(typeof finalText === "string" && /\[\[\s*reply_to(?:_current|\s*:)/i.test(finalText));
511560

512561
const result = await deliverWithFinalizableLivePreviewAdapter({
513562
kind: info.kind,
514-
payload,
563+
payload: effectivePayload,
515564
adapter: defineFinalizableLivePreviewAdapter({
516565
draft: {
517566
flush: () => draftPreview.flush(),
@@ -566,7 +615,7 @@ export async function processDiscordMessage(
566615
notifyFinalReplyStart();
567616
await deliverDiscordReply({
568617
cfg,
569-
replies: [payload],
618+
replies: [effectivePayload],
570619
target: deliverTarget,
571620
token,
572621
accountId,
@@ -604,7 +653,7 @@ export async function processDiscordMessage(
604653
}
605654
await deliverDiscordReply({
606655
cfg,
607-
replies: [payload],
656+
replies: [effectivePayload],
608657
target: deliverTarget,
609658
token,
610659
accountId,

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
resolveChannelProgressDraftMaxLines,
2727
resolveChannelStreamingBlockEnabled,
2828
resolveChannelStreamingPreviewToolProgress,
29+
resolveTranscriptBackedChannelFinalText,
2930
} from "openclaw/plugin-sdk/channel-streaming";
3031
import { isAbortRequestText } from "openclaw/plugin-sdk/command-primitives-runtime";
3132
import type {
@@ -100,8 +101,6 @@ import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-de
100101
import {
101102
createLaneDeliveryStateTracker,
102103
createLaneTextDeliverer,
103-
isPotentialTruncatedFinal,
104-
selectLongerFinalText,
105104
type DraftLaneState,
106105
type LaneDeliveryResult,
107106
type LaneName,
@@ -1283,12 +1282,10 @@ export const dispatchTelegramMessage = async ({
12831282
return delivered ? { kind: "sent" } : { kind: "skipped" };
12841283
};
12851284
const resolveTranscriptBackedFinalText = async (text: string): Promise<string> =>
1286-
isPotentialTruncatedFinal(text)
1287-
? (selectLongerFinalText({
1288-
finalText: text,
1289-
candidateTexts: [await resolveCurrentTurnTranscriptFinalText()],
1290-
}) ?? text)
1291-
: text;
1285+
await resolveTranscriptBackedChannelFinalText({
1286+
finalText: text,
1287+
resolveCandidateText: resolveCurrentTurnTranscriptFinalText,
1288+
});
12921289

12931290
if (isDmTopic) {
12941291
try {

extensions/telegram/src/lane-delivery-text-deliverer.ts

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import {
22
createPreviewMessageReceipt,
33
type MessageReceipt,
44
} from "openclaw/plugin-sdk/channel-message";
5+
import {
6+
isPotentialTruncatedFinal,
7+
selectLongerFinalText,
8+
} from "openclaw/plugin-sdk/channel-streaming";
59
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
610
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
711
import type { TelegramInlineButtons } from "./button-types.js";
@@ -106,50 +110,6 @@ function compactChunks(chunks: readonly string[]): string[] {
106110
return out;
107111
}
108112

109-
function stripTrailingEllipsis(text: string): string {
110-
return text.replace(/(?:\s*(?:\.{3}|\u2026))+$/u, "").trimEnd();
111-
}
112-
113-
const MIN_TRUNCATED_FINAL_PREFIX_CHARS = 48;
114-
const MIN_TRUNCATED_FINAL_CONTINUATION_CHARS = 24;
115-
116-
export function isPotentialTruncatedFinal(finalText: string): boolean {
117-
const trimmedFinal = finalText.trimEnd();
118-
const untruncatedFinal = stripTrailingEllipsis(trimmedFinal);
119-
return (
120-
untruncatedFinal.length >= MIN_TRUNCATED_FINAL_PREFIX_CHARS && untruncatedFinal !== trimmedFinal
121-
);
122-
}
123-
124-
export function selectLongerFinalText(params: {
125-
finalText: string;
126-
candidateTexts: readonly (string | undefined)[];
127-
}): string | undefined {
128-
const finalText = params.finalText.trimEnd();
129-
if (!isPotentialTruncatedFinal(finalText)) {
130-
return undefined;
131-
}
132-
const untruncatedFinal = stripTrailingEllipsis(finalText);
133-
for (const candidate of params.candidateTexts) {
134-
const candidateText = candidate?.trimEnd();
135-
if (
136-
!candidateText ||
137-
candidateText.length <= finalText.length ||
138-
!candidateText.startsWith(untruncatedFinal)
139-
) {
140-
continue;
141-
}
142-
const continuation = candidateText.slice(untruncatedFinal.length).trimStart();
143-
if (
144-
continuation.length >= MIN_TRUNCATED_FINAL_CONTINUATION_CHARS &&
145-
/^[\p{L}\p{N}]/u.test(continuation)
146-
) {
147-
return candidateText;
148-
}
149-
}
150-
return undefined;
151-
}
152-
153113
export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
154114
const followUpPayload = (payload: ReplyPayload, text: string) =>
155115
params.applyTextToFollowUpPayload

extensions/telegram/src/lane-delivery.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
export {
2-
createLaneTextDeliverer,
32
isPotentialTruncatedFinal,
43
selectLongerFinalText,
4+
} from "openclaw/plugin-sdk/channel-streaming";
5+
export {
6+
createLaneTextDeliverer,
57
type DraftLaneState,
68
type LaneDeliveryResult,
79
type LaneName,

0 commit comments

Comments
 (0)