mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 22:54:46 +00:00
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:
committed by
GitHub
parent
39a9a3478f
commit
549a0ea313
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export {
|
||||
createLaneTextDeliverer,
|
||||
isPotentialTruncatedFinal,
|
||||
selectLongerFinalText,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
export {
|
||||
createLaneTextDeliverer,
|
||||
type DraftLaneState,
|
||||
type LaneDeliveryResult,
|
||||
type LaneName,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user