mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 15:24:46 +00:00
fix(telegram): retain transcript-backed truncated finals
This commit is contained in:
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: validate message-read results before normalizing channel history and report unexpected payloads with a Discord boundary error instead of `map is not a function`. Fixes #82252. Thanks @jessewunderlich.
|
||||
- Agents/runtime: apply `agents.defaults.models["provider/*"].agentRuntime` as provider-wide model runtime policy while preserving exact model runtime precedence. Fixes #82243. Thanks @rendrag-git.
|
||||
- Agents/auto-reply: restrict `NO_REPLY` prompt guidance to automatic group/channel replies, remove legacy silent-reply rewrites, and suppress accidental direct-chat silent tokens instead of delivering fallback text. Fixes #82254. Thanks @absol89.
|
||||
- Telegram: retain a longer partial-stream preview when a final callback only carries an ellipsis-truncated snapshot, preventing the visible answer and transcript mirror from being replaced by the short preview. Fixes #82239.
|
||||
- Telegram/active-memory: run blocking memory recall through the Telegram provider for direct-message turns even when the hook context carries the raw chat id, preventing embedded recall from launching against an invalid numeric channel. Fixes #82177. Thanks @cslash-zz.
|
||||
- Control UI/WebChat: keep optimistic image messages from embedding large inline `data:` previews and preserve image-only user turns in chat history, avoiding browser stack overflows when sending image attachments. Fixes #82182. Thanks @ExploreSheep.
|
||||
- Agents/media: preserve message-tool-only delivery for generated music and video completion handoffs, so group/channel completions do not finish without posting the generated attachment.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
loadSessionStore,
|
||||
readLatestAssistantTextFromSessionTranscript,
|
||||
resolveAndPersistSessionFile,
|
||||
resolveSessionStoreEntry,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
|
||||
@@ -58,6 +58,7 @@ const appendSessionTranscriptMessage = vi.hoisted(() =>
|
||||
);
|
||||
const emitSessionTranscriptUpdate = vi.hoisted(() => vi.fn());
|
||||
const loadSessionStore = vi.hoisted(() => vi.fn());
|
||||
const readLatestAssistantTextFromSessionTranscript = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePath = vi.hoisted(() => vi.fn(() => "/tmp/sessions.json"));
|
||||
const resolveAndPersistSessionFile = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
@@ -131,6 +132,7 @@ vi.mock("./bot-message-dispatch.runtime.js", () => ({
|
||||
generateTopicLabel,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
loadSessionStore,
|
||||
readLatestAssistantTextFromSessionTranscript,
|
||||
resolveAndPersistSessionFile,
|
||||
resolveAutoTopicLabelConfig: resolveAutoTopicLabelConfigRuntime,
|
||||
resolveChunkMode,
|
||||
@@ -219,6 +221,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
wasSentByBot.mockReset();
|
||||
appendSessionTranscriptMessage.mockReset();
|
||||
emitSessionTranscriptUpdate.mockReset();
|
||||
readLatestAssistantTextFromSessionTranscript.mockReset();
|
||||
loadSessionStore.mockReset();
|
||||
resolveStorePath.mockReset();
|
||||
resolveAndPersistSessionFile.mockReset();
|
||||
@@ -962,6 +965,48 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors the longer streamed preview when final text is truncated", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
|
||||
const truncatedFinal =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
|
||||
const context = createContext();
|
||||
context.ctxPayload.SessionKey = "agent:default:telegram:direct:123";
|
||||
loadSessionStore.mockReturnValue({
|
||||
"agent:default:telegram:direct:123": { sessionId: "s1" },
|
||||
});
|
||||
readLatestAssistantTextFromSessionTranscript.mockResolvedValue({
|
||||
text: fullAnswer,
|
||||
timestamp: Date.now() + 1_000,
|
||||
});
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({ text: fullAnswer });
|
||||
await dispatcherOptions.deliver({ text: truncatedFinal }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({ context });
|
||||
|
||||
expect(answerDraftStream.update).toHaveBeenCalledWith(fullAnswer);
|
||||
expect(answerDraftStream.update).not.toHaveBeenCalledWith(truncatedFinal);
|
||||
expectRecordFields(mockCallArg(emitInternalMessageSentHook), {
|
||||
content: fullAnswer,
|
||||
messageId: 2001,
|
||||
});
|
||||
const transcriptCall = expectRecordFields(mockCallArg(appendSessionTranscriptMessage), {
|
||||
transcriptPath: "/tmp/session.jsonl",
|
||||
});
|
||||
expectRecordFields(transcriptCall.message, {
|
||||
role: "assistant",
|
||||
provider: "openclaw",
|
||||
model: "delivery-mirror",
|
||||
content: [{ type: "text", text: fullAnswer }],
|
||||
});
|
||||
});
|
||||
|
||||
it("emits the redacted appended message in transcript updates", async () => {
|
||||
setupDraftStreams({ answerMessageId: 2001 });
|
||||
const context = createContext();
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
generateTopicLabel,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
loadSessionStore,
|
||||
readLatestAssistantTextFromSessionTranscript,
|
||||
resolveAutoTopicLabelConfig,
|
||||
resolveChunkMode,
|
||||
resolveMarkdownTableMode,
|
||||
@@ -442,6 +443,7 @@ export const dispatchTelegramMessage = async ({
|
||||
telegramDeps: injectedTelegramDeps,
|
||||
opts,
|
||||
}: DispatchTelegramMessageParams) => {
|
||||
const dispatchStartedAt = Date.now();
|
||||
const telegramDeps =
|
||||
injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps;
|
||||
const {
|
||||
@@ -962,6 +964,43 @@ export const dispatchTelegramMessage = async ({
|
||||
);
|
||||
const endTelegramInboundTurnDeliveryCorrelation = beginDeliveryCorrelation();
|
||||
const sessionKey = ctxPayload.SessionKey;
|
||||
const resolveCurrentTurnTranscriptFinalText = async (): Promise<string | undefined> => {
|
||||
if (!sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
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(`telegram transcript final candidate lookup failed: ${formatErrorMessage(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const deliveryBaseOptions = {
|
||||
chatId: String(chatId),
|
||||
accountId: route.accountId,
|
||||
@@ -1201,6 +1240,7 @@ export const dispatchTelegramMessage = async ({
|
||||
buttons,
|
||||
});
|
||||
},
|
||||
resolveFinalTextCandidate: () => resolveCurrentTurnTranscriptFinalText(),
|
||||
log: logVerbose,
|
||||
markDelivered: () => {
|
||||
deliveryState.markDelivered();
|
||||
|
||||
@@ -52,6 +52,10 @@ type CreateLaneTextDelivererParams = {
|
||||
text: string;
|
||||
buttons?: TelegramInlineButtons;
|
||||
}) => Promise<void>;
|
||||
resolveFinalTextCandidate?: (params: {
|
||||
finalText: string;
|
||||
laneName: LaneName;
|
||||
}) => Promise<string | undefined> | string | undefined;
|
||||
log: (message: string) => void;
|
||||
markDelivered: () => void;
|
||||
};
|
||||
@@ -101,6 +105,53 @@ 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;
|
||||
|
||||
function isPotentialTruncatedFinal(finalText: string): boolean {
|
||||
const trimmedFinal = finalText.trimEnd();
|
||||
const untruncatedFinal = stripTrailingEllipsis(trimmedFinal);
|
||||
return (
|
||||
untruncatedFinal.length >= MIN_TRUNCATED_FINAL_PREFIX_CHARS && untruncatedFinal !== trimmedFinal
|
||||
);
|
||||
}
|
||||
|
||||
function selectLongerPreviewForFinal(params: {
|
||||
finalText: string;
|
||||
candidateTexts: readonly (string | undefined)[];
|
||||
}): string | undefined {
|
||||
const finalText = params.finalText.trimEnd();
|
||||
const untruncatedFinal = stripTrailingEllipsis(finalText);
|
||||
if (
|
||||
untruncatedFinal.length < MIN_TRUNCATED_FINAL_PREFIX_CHARS ||
|
||||
untruncatedFinal === finalText
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
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
|
||||
@@ -138,6 +189,53 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retainedPreview =
|
||||
isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(text)
|
||||
? selectLongerPreviewForFinal({
|
||||
finalText: text,
|
||||
candidateTexts: [
|
||||
await params.resolveFinalTextCandidate?.({ finalText: text, laneName }),
|
||||
stream.lastDeliveredText?.(),
|
||||
lane.lastPartialText,
|
||||
],
|
||||
})
|
||||
: undefined;
|
||||
if (retainedPreview && (!buttons || retainedPreview.length <= params.draftMaxChars)) {
|
||||
const previewText = retainedPreview;
|
||||
lane.lastPartialText = previewText;
|
||||
lane.hasStreamedMessage = true;
|
||||
await params.stopDraftLane(lane);
|
||||
const messageId = stream.messageId();
|
||||
if (typeof messageId !== "number") {
|
||||
if (stream.sendMayHaveLanded?.()) {
|
||||
lane.finalized = true;
|
||||
params.markDelivered();
|
||||
return result("preview-retained");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const deliveredStreamText = stream.lastDeliveredText?.();
|
||||
if (deliveredStreamText !== undefined && deliveredStreamText !== previewText) {
|
||||
return undefined;
|
||||
}
|
||||
if (buttons) {
|
||||
try {
|
||||
await params.editStreamMessage({ laneName, messageId, text: previewText, buttons });
|
||||
} catch (err) {
|
||||
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
for (const chunk of remainingChunks) {
|
||||
if (chunk.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
await params.sendPayload(followUpPayload(payload, chunk));
|
||||
}
|
||||
lane.finalized = true;
|
||||
params.markDelivered();
|
||||
return result("preview-finalized", { content: previewText, messageId });
|
||||
}
|
||||
|
||||
lane.lastPartialText = firstChunk;
|
||||
lane.hasStreamedMessage = true;
|
||||
lane.finalized = false;
|
||||
|
||||
@@ -15,6 +15,10 @@ function createHarness(params?: {
|
||||
answerStream?: DraftLaneState["stream"] | null;
|
||||
draftMaxChars?: number;
|
||||
splitFinalTextForStream?: (text: string) => readonly string[];
|
||||
resolveFinalTextCandidate?: (params: {
|
||||
finalText: string;
|
||||
laneName: LaneName;
|
||||
}) => string | undefined;
|
||||
}) {
|
||||
const answer =
|
||||
params?.answerStream === null
|
||||
@@ -59,6 +63,7 @@ function createHarness(params?: {
|
||||
stopDraftLane,
|
||||
clearDraftLane,
|
||||
editStreamMessage,
|
||||
resolveFinalTextCandidate: params?.resolveFinalTextCandidate,
|
||||
log,
|
||||
markDelivered,
|
||||
});
|
||||
@@ -151,6 +156,229 @@ describe("createLaneTextDeliverer", () => {
|
||||
expect(harness.lanes.answer.finalized).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a longer partial preview when the final payload is an ellipsis-truncated snapshot", async () => {
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
|
||||
const truncatedFinal =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue(fullAnswer);
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
resolveFinalTextCandidate: () => fullAnswer,
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, truncatedFinal);
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe(fullAnswer);
|
||||
expect(delivery.messageId).toBe(999);
|
||||
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
|
||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
expect(harness.lanes.answer.finalized).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a longer delivered stream preview when transcript lookup misses", async () => {
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
|
||||
const truncatedFinal =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue(fullAnswer);
|
||||
const harness = createHarness({ answerStream: answer });
|
||||
harness.lanes.answer.lastPartialText = fullAnswer;
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
|
||||
const result = await deliverFinalAnswer(harness, truncatedFinal);
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe(fullAnswer);
|
||||
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a longer pending partial preview before it is delivered", async () => {
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
|
||||
const truncatedFinal =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
|
||||
let deliveredText = "";
|
||||
const answer = createTestDraftStream({
|
||||
messageId: 999,
|
||||
onStop: () => {
|
||||
deliveredText = fullAnswer;
|
||||
},
|
||||
});
|
||||
answer.lastDeliveredText.mockImplementation(() => deliveredText);
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
resolveFinalTextCandidate: () => fullAnswer,
|
||||
});
|
||||
|
||||
answer.update(fullAnswer);
|
||||
harness.lanes.answer.lastPartialText = fullAnswer;
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
const result = await deliverFinalAnswer(harness, truncatedFinal);
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe(fullAnswer);
|
||||
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
|
||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("materializes a pending retained preview before reading the message id", async () => {
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console. Danach pruefst du die Projekt- und API-Einstellungen.";
|
||||
const truncatedFinal =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man...";
|
||||
let answer: ReturnType<typeof createTestDraftStream>;
|
||||
let deliveredText = "";
|
||||
answer = createTestDraftStream({
|
||||
onStop: () => {
|
||||
answer.setMessageId(999);
|
||||
deliveredText = fullAnswer;
|
||||
},
|
||||
});
|
||||
answer.lastDeliveredText.mockImplementation(() => deliveredText);
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
resolveFinalTextCandidate: () => fullAnswer,
|
||||
});
|
||||
|
||||
answer.update(fullAnswer);
|
||||
harness.lanes.answer.lastPartialText = fullAnswer;
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
const result = await deliverFinalAnswer(harness, truncatedFinal);
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe(fullAnswer);
|
||||
expect(delivery.messageId).toBe(999);
|
||||
expect(answer.update).not.toHaveBeenCalledWith(truncatedFinal);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when the retained pending preview does not land", async () => {
|
||||
const fullAnswer =
|
||||
"Ja. Hier nochmal sauber Schritt fuer Schritt. Einen API Key kopiert man aus der Google Cloud Console.";
|
||||
const truncatedFinal = "Ja. Hier nochmal sauber Schritt fuer Schritt...";
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue("older preview");
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
resolveFinalTextCandidate: () => fullAnswer,
|
||||
});
|
||||
harness.lanes.answer.lastPartialText = fullAnswer;
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
|
||||
const result = await deliverFinalAnswer(harness, truncatedFinal);
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.clearDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: truncatedFinal }, { durable: true });
|
||||
});
|
||||
|
||||
it("uses the canonical final when the shorter final has no truncation marker", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue("Hello world");
|
||||
const harness = createHarness({ answerStream: answer });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Hello");
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answer.update).toHaveBeenCalledWith("Hello");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: "Hello" }, { durable: true });
|
||||
});
|
||||
|
||||
it("uses the canonical final when the shorter final intentionally ends with ellipsis", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue("Let's leave it... and continue");
|
||||
const harness = createHarness({ answerStream: answer });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Let's leave it...");
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answer.update).toHaveBeenCalledWith("Let's leave it...");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
{ text: "Let's leave it..." },
|
||||
{ durable: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the canonical final when an intentional ellipsis replaces a longer draft", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue("I don't know the answer");
|
||||
const harness = createHarness({ answerStream: answer });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "I don't know...");
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answer.update).toHaveBeenCalledWith("I don't know...");
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
{ text: "I don't know..." },
|
||||
{ durable: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the canonical split final when only the first chunk ends with ellipsis", async () => {
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
draftMaxChars: 10,
|
||||
splitFinalTextForStream: () => ["Hello...", " world"],
|
||||
});
|
||||
harness.lanes.answer.lastPartialText = "Hello retained preview";
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Hello... world",
|
||||
payload: { text: "Hello... world" },
|
||||
infoKind: "final",
|
||||
buttons,
|
||||
});
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe("Hello... world");
|
||||
expect(answer.update).toHaveBeenCalledWith("Hello...");
|
||||
expect(harness.editStreamMessage).toHaveBeenCalledWith({
|
||||
laneName: "answer",
|
||||
messageId: 999,
|
||||
text: "Hello...",
|
||||
buttons,
|
||||
});
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " world" });
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses normal final delivery when retained preview is too long for button edit", async () => {
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue("Hello retained preview");
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
draftMaxChars: 10,
|
||||
resolveFinalTextCandidate: () => "Hello retained preview",
|
||||
});
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Hello...",
|
||||
payload: { text: "Hello..." },
|
||||
infoKind: "final",
|
||||
buttons,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answer.update).toHaveBeenCalledWith("Hello...");
|
||||
expect(harness.editStreamMessage).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: "Hello..." }, { durable: true });
|
||||
});
|
||||
|
||||
it("falls back to normal delivery when no stream exists", async () => {
|
||||
const harness = createHarness({ answerStream: null });
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export { loadSessionStore } from "../config/sessions/store-load.js";
|
||||
export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
|
||||
export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js";
|
||||
export { resolveAndPersistSessionFile } from "../config/sessions/session-file.js";
|
||||
export { readLatestAssistantTextFromSessionTranscript } from "../config/sessions/transcript.js";
|
||||
export { resolveSessionKey } from "../config/sessions/session-key.js";
|
||||
export { resolveGroupSessionKey } from "../config/sessions/group.js";
|
||||
export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
|
||||
|
||||
Reference in New Issue
Block a user