mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
test(telegram): cover single stream delivery
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,7 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createTestDraftStream } from "./draft-stream.test-helpers.js";
|
||||
import {
|
||||
createSequencedTestDraftStream,
|
||||
createTestDraftStream,
|
||||
} from "./draft-stream.test-helpers.js";
|
||||
import {
|
||||
type ArchivedPreview,
|
||||
createLaneTextDeliverer,
|
||||
type DraftLaneState,
|
||||
type LaneDeliveryResult,
|
||||
@@ -16,32 +12,27 @@ const HELLO_FINAL = "Hello final";
|
||||
|
||||
function createHarness(params?: {
|
||||
answerMessageId?: number;
|
||||
answerStream?: DraftLaneState["stream"] | null;
|
||||
draftMaxChars?: number;
|
||||
answerMessageIdAfterStop?: number;
|
||||
answerStream?: DraftLaneState["stream"];
|
||||
answerHasStreamedMessage?: boolean;
|
||||
answerLastPartialText?: string;
|
||||
answerPreviewVisibleSinceMs?: number;
|
||||
splitFinalTextForPreview?: (text: string) => readonly string[];
|
||||
nowMs?: number;
|
||||
splitFinalTextForStream?: (text: string) => readonly string[];
|
||||
}) {
|
||||
const answer =
|
||||
params?.answerStream ??
|
||||
createTestDraftStream({
|
||||
messageId: params?.answerMessageId,
|
||||
visibleSinceMs: params?.answerPreviewVisibleSinceMs,
|
||||
});
|
||||
params?.answerStream === null
|
||||
? undefined
|
||||
: (params?.answerStream ?? createTestDraftStream({ messageId: params?.answerMessageId }));
|
||||
const reasoning = createTestDraftStream();
|
||||
const lanes: Record<LaneName, DraftLaneState> = {
|
||||
answer: {
|
||||
stream: answer,
|
||||
lastPartialText: params?.answerLastPartialText ?? "",
|
||||
hasStreamedMessage: params?.answerHasStreamedMessage ?? false,
|
||||
},
|
||||
reasoning: {
|
||||
stream: reasoning as DraftLaneState["stream"],
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
finalized: false,
|
||||
},
|
||||
reasoning: {
|
||||
stream: reasoning,
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
finalized: false,
|
||||
},
|
||||
};
|
||||
const sendPayload = vi.fn().mockResolvedValue(true);
|
||||
@@ -49,54 +40,41 @@ function createHarness(params?: {
|
||||
await lane.stream?.flush();
|
||||
});
|
||||
const stopDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => {
|
||||
if (lane === lanes.answer && params?.answerMessageIdAfterStop !== undefined) {
|
||||
(answer as { setMessageId?: (value: number | undefined) => void }).setMessageId?.(
|
||||
params.answerMessageIdAfterStop,
|
||||
);
|
||||
}
|
||||
await lane.stream?.stop();
|
||||
});
|
||||
const editPreview = vi.fn().mockResolvedValue(undefined);
|
||||
const deletePreviewMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const clearDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => {
|
||||
await lane.stream?.clear();
|
||||
});
|
||||
const editStreamMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const log = vi.fn();
|
||||
const markDelivered = vi.fn();
|
||||
const activePreviewLifecycleByLane = { answer: "transient", reasoning: "transient" } as const;
|
||||
const retainPreviewOnCleanupByLane = { answer: false, reasoning: false } as const;
|
||||
const archivedAnswerPreviews: ArchivedPreview[] = [];
|
||||
|
||||
const deliverLaneText = createLaneTextDeliverer({
|
||||
lanes,
|
||||
archivedAnswerPreviews,
|
||||
activePreviewLifecycleByLane: { ...activePreviewLifecycleByLane },
|
||||
retainPreviewOnCleanupByLane: { ...retainPreviewOnCleanupByLane },
|
||||
draftMaxChars: params?.draftMaxChars ?? 4_096,
|
||||
applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }),
|
||||
splitFinalTextForPreview: params?.splitFinalTextForPreview,
|
||||
splitFinalTextForStream: params?.splitFinalTextForStream,
|
||||
sendPayload,
|
||||
flushDraftLane,
|
||||
stopDraftLane,
|
||||
editPreview,
|
||||
deletePreviewMessage,
|
||||
clearDraftLane,
|
||||
editStreamMessage,
|
||||
log,
|
||||
markDelivered,
|
||||
now: params?.nowMs != null ? () => params.nowMs! : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
deliverLaneText,
|
||||
lanes,
|
||||
answer: {
|
||||
stream: answer,
|
||||
setMessageId: (answer as { setMessageId?: (value: number | undefined) => void }).setMessageId,
|
||||
},
|
||||
answer,
|
||||
reasoning,
|
||||
sendPayload,
|
||||
flushDraftLane,
|
||||
stopDraftLane,
|
||||
editPreview,
|
||||
deletePreviewMessage,
|
||||
clearDraftLane,
|
||||
editStreamMessage,
|
||||
log,
|
||||
markDelivered,
|
||||
archivedAnswerPreviews,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,589 +87,183 @@ async function deliverFinalAnswer(harness: ReturnType<typeof createHarness>, tex
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFinalPreviewRetained(params: {
|
||||
harness: ReturnType<typeof createHarness>;
|
||||
text?: string;
|
||||
expectedLogSnippet?: string;
|
||||
}) {
|
||||
const result = await deliverFinalAnswer(params.harness, params.text ?? HELLO_FINAL);
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(params.harness.sendPayload).not.toHaveBeenCalled();
|
||||
if (params.expectedLogSnippet) {
|
||||
expect(params.harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(params.expectedLogSnippet),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedArchivedAnswerPreview(harness: ReturnType<typeof createHarness>) {
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 5555,
|
||||
textSnapshot: "Partial streaming...",
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFinalEditFallbackToSend(params: {
|
||||
harness: ReturnType<typeof createHarness>;
|
||||
text: string;
|
||||
expectedLogSnippet: string;
|
||||
}) {
|
||||
const result = await deliverFinalAnswer(params.harness, params.text);
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(params.harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expectSendPayloadWith(params.harness, { text: params.text });
|
||||
expect(params.harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(params.expectedLogSnippet),
|
||||
);
|
||||
}
|
||||
|
||||
function expectSendPayloadWith(
|
||||
harness: ReturnType<typeof createHarness>,
|
||||
expected: Partial<ReplyPayload>,
|
||||
) {
|
||||
expect(
|
||||
harness.sendPayload.mock.calls.some(([payload]) =>
|
||||
Object.entries(expected).every(([key, value]) => {
|
||||
return (payload as Record<string, unknown>)[key] === value;
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
function expectPreviewFinalized(result: LaneDeliveryResult): {
|
||||
content: string;
|
||||
messageId: number;
|
||||
} {
|
||||
function expectPreviewFinalized(
|
||||
result: LaneDeliveryResult,
|
||||
): Extract<LaneDeliveryResult, { kind: "preview-finalized" }>["delivery"] {
|
||||
expect(result.kind).toBe("preview-finalized");
|
||||
if (result.kind !== "preview-finalized") {
|
||||
throw new Error(`expected preview-finalized, got ${result.kind}`);
|
||||
}
|
||||
expect(result.delivery.receipt).toEqual(
|
||||
expect.objectContaining({
|
||||
primaryPlatformMessageId: String(result.delivery.messageId),
|
||||
platformMessageIds: [String(result.delivery.messageId)],
|
||||
}),
|
||||
);
|
||||
return {
|
||||
content: result.delivery.content,
|
||||
messageId: result.delivery.messageId,
|
||||
};
|
||||
return result.delivery;
|
||||
}
|
||||
|
||||
describe("createLaneTextDeliverer", () => {
|
||||
it("finalizes text-only replies by editing an existing preview message", async () => {
|
||||
it("finalizes text-only replies in the active stream message", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 });
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
laneName: "answer",
|
||||
messageId: 999,
|
||||
text: HELLO_FINAL,
|
||||
context: "final",
|
||||
}),
|
||||
);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(expectPreviewFinalized(result)).toMatchObject({
|
||||
content: HELLO_FINAL,
|
||||
messageId: 999,
|
||||
receipt: { primaryPlatformMessageId: "999" },
|
||||
});
|
||||
expect(harness.answer?.update).toHaveBeenCalledWith(HELLO_FINAL);
|
||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("primes stop-created previews with final text before editing", async () => {
|
||||
const harness = createHarness({
|
||||
answerMessageIdAfterStop: 777,
|
||||
answerHasStreamedMessage: true,
|
||||
});
|
||||
harness.lanes.answer.lastPartialText = "no";
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "no problem",
|
||||
payload: { text: "no problem" },
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: "no problem", messageId: 777 });
|
||||
expect(harness.answer.stream?.update).toHaveBeenCalledWith("no problem");
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
laneName: "answer",
|
||||
messageId: 777,
|
||||
text: "no problem",
|
||||
}),
|
||||
);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps stop-created preview when follow-up final edit fails", async () => {
|
||||
const harness = createHarness({
|
||||
answerMessageIdAfterStop: 777,
|
||||
answerHasStreamedMessage: true,
|
||||
});
|
||||
harness.editPreview.mockRejectedValue(new Error("500: edit failed after stop flush"));
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Short final",
|
||||
payload: { text: "Short final" },
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed after stop flush; keeping existing preview"),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats 'message is not modified' preview edit errors as delivered", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
harness.editPreview.mockRejectedValue(
|
||||
new Error(
|
||||
"400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message",
|
||||
),
|
||||
);
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({ content: HELLO_FINAL, messageId: 999 });
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('edit returned "message is not modified"; treating as delivered'),
|
||||
);
|
||||
expect(harness.lanes.answer.finalized).toBe(true);
|
||||
});
|
||||
|
||||
it("retains preview when an existing preview final edit fails with ambiguous error", async () => {
|
||||
it("streams block and final text through the same lane", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
// Plain Error with no error_code → ambiguous. Retain unless the preview is
|
||||
// known to be an incomplete prefix of the final text.
|
||||
harness.editPreview.mockRejectedValue(new Error("500: preview edit failed"));
|
||||
|
||||
await expectFinalPreviewRetained({
|
||||
harness,
|
||||
expectedLogSnippet: "ambiguous error; keeping existing preview to avoid duplicate",
|
||||
});
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when an ambiguous final edit failure would leave an incomplete preview", async () => {
|
||||
const harness = createHarness({
|
||||
answerMessageId: 999,
|
||||
answerLastPartialText: "Hello fi",
|
||||
});
|
||||
harness.editPreview.mockRejectedValue(new Error("500: preview edit failed"));
|
||||
|
||||
await expectFinalEditFallbackToSend({
|
||||
harness,
|
||||
text: HELLO_FINAL,
|
||||
expectedLogSnippet: "preview is an incomplete prefix; falling back",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when Telegram reports the current final edit target missing", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found"));
|
||||
|
||||
await expectFinalEditFallbackToSend({
|
||||
harness,
|
||||
text: "Hello final",
|
||||
expectedLogSnippet: "edit target missing with no alternate preview; falling back",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to sendPayload when the final edit fails before reaching Telegram", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
const err = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
|
||||
harness.editPreview.mockRejectedValue(err);
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expectSendPayloadWith(harness, { text: HELLO_FINAL });
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed before reaching Telegram; falling back"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps preview when the final edit times out after the request may have landed", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
harness.editPreview.mockRejectedValue(new Error("timeout: request timed out after 30000ms"));
|
||||
|
||||
await expectFinalPreviewRetained({
|
||||
harness,
|
||||
expectedLogSnippet: "may have landed despite network error; keeping existing preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to normal delivery when stop-created preview has no message id", async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
const blockResult = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Short final",
|
||||
payload: { text: "Short final" },
|
||||
infoKind: "final",
|
||||
text: "working",
|
||||
payload: { text: "working" },
|
||||
infoKind: "block",
|
||||
});
|
||||
const finalResult = await deliverFinalAnswer(harness, "done");
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expectSendPayloadWith(harness, { text: "Short final" });
|
||||
});
|
||||
|
||||
it("does not create a synthetic preview for final-only text", async () => {
|
||||
const answerStream = createSequencedTestDraftStream(777);
|
||||
const harness = createHarness({
|
||||
answerStream: answerStream as DraftLaneState["stream"],
|
||||
answerHasStreamedMessage: false,
|
||||
});
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Final only",
|
||||
payload: { text: "Final only" },
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answerStream.update).not.toHaveBeenCalled();
|
||||
expect(answerStream.materialize).not.toHaveBeenCalled();
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expectSendPayloadWith(harness, { text: "Final only" });
|
||||
});
|
||||
|
||||
it("keeps existing preview when final text regresses", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
harness.lanes.answer.lastPartialText = "Recovered final answer.";
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Recovered final answer",
|
||||
payload: { text: "Recovered final answer" },
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: "Recovered final answer.",
|
||||
expect(blockResult.kind).toBe("preview-updated");
|
||||
expect(expectPreviewFinalized(finalResult)).toMatchObject({
|
||||
content: "done",
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to normal delivery when final text exceeds preview edit limit", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999, draftMaxChars: 20 });
|
||||
const longText = "x".repeat(50);
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: longText,
|
||||
payload: { text: longText },
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expectSendPayloadWith(harness, { text: longText });
|
||||
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
|
||||
});
|
||||
|
||||
it("forces a long final preview back to the first chunk before sending the rest", async () => {
|
||||
const firstChunk = "First chunk boundary.";
|
||||
const remainingText = " Follow-up body after the boundary.";
|
||||
const finalText = `${firstChunk}${remainingText}`;
|
||||
const harness = createHarness({
|
||||
answerMessageId: 999,
|
||||
answerHasStreamedMessage: true,
|
||||
answerLastPartialText: `${firstChunk} overlap already visible`,
|
||||
draftMaxChars: 24,
|
||||
splitFinalTextForPreview: () => [firstChunk, remainingText],
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, finalText);
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: finalText,
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 999,
|
||||
text: firstChunk,
|
||||
}),
|
||||
);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: remainingText }),
|
||||
);
|
||||
expect(harness.lanes.answer.lastPartialText).toBe(firstChunk);
|
||||
});
|
||||
|
||||
it("sends a fresh final when a message preview is long lived", async () => {
|
||||
const visibleSinceMs = 10_000;
|
||||
const harness = createHarness({
|
||||
answerMessageId: 999,
|
||||
answerHasStreamedMessage: true,
|
||||
answerLastPartialText: "Working...",
|
||||
answerPreviewVisibleSinceMs: visibleSinceMs,
|
||||
nowMs: visibleSinceMs + 60_000,
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.answer?.update).toHaveBeenNthCalledWith(1, "working");
|
||||
expect(harness.answer?.update).toHaveBeenNthCalledWith(2, "done");
|
||||
expect(harness.flushDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
|
||||
expectSendPayloadWith(harness, { text: HELLO_FINAL });
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.answer.stream?.clear).toHaveBeenCalledTimes(1);
|
||||
expect(harness.answer.stream?.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
expect(harness.lanes.answer.hasStreamedMessage).toBe(false);
|
||||
expect(harness.lanes.answer.lastPartialText).toBe("");
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses normal final delivery when the stream edit leaves stale text", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
answer.lastDeliveredText.mockReturnValue("working");
|
||||
const harness = createHarness({ answerStream: answer });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "done");
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(answer.update).toHaveBeenCalledWith("done");
|
||||
expect(harness.clearDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: "done" }, { durable: true });
|
||||
expect(harness.markDelivered).not.toHaveBeenCalled();
|
||||
expect(harness.lanes.answer.finalized).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to editing a long-lived preview when fresh final send returns false", async () => {
|
||||
const visibleSinceMs = 10_000;
|
||||
const harness = createHarness({
|
||||
answerMessageId: 999,
|
||||
answerHasStreamedMessage: true,
|
||||
answerLastPartialText: "Working...",
|
||||
answerPreviewVisibleSinceMs: visibleSinceMs,
|
||||
nowMs: visibleSinceMs + 60_000,
|
||||
});
|
||||
harness.sendPayload.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: HELLO_FINAL,
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.stopDraftLane).toHaveBeenCalledTimes(2);
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 999,
|
||||
text: HELLO_FINAL,
|
||||
}),
|
||||
);
|
||||
expect(harness.answer.stream?.clear).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends a fresh final for stale archived previews", async () => {
|
||||
const visibleSinceMs = 10_000;
|
||||
const harness = createHarness({
|
||||
answerMessageId: 1001,
|
||||
answerPreviewVisibleSinceMs: visibleSinceMs,
|
||||
nowMs: visibleSinceMs + 60_000,
|
||||
});
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 222,
|
||||
textSnapshot: "Working...",
|
||||
visibleSinceMs,
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
it("falls back to normal delivery when no stream exists", async () => {
|
||||
const harness = createHarness({ answerStream: null });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expectSendPayloadWith(harness, { text: HELLO_FINAL });
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(222);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: HELLO_FINAL }, { durable: true });
|
||||
expect(harness.clearDraftLane).not.toHaveBeenCalled();
|
||||
expect(harness.lanes.answer.finalized).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to editing a stale archived preview when fresh final send returns false", async () => {
|
||||
const visibleSinceMs = 10_000;
|
||||
const harness = createHarness({
|
||||
answerMessageId: 1001,
|
||||
answerPreviewVisibleSinceMs: visibleSinceMs,
|
||||
nowMs: visibleSinceMs + 60_000,
|
||||
});
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 222,
|
||||
textSnapshot: "Working...",
|
||||
visibleSinceMs,
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
harness.sendPayload.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: HELLO_FINAL,
|
||||
messageId: 222,
|
||||
});
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 222,
|
||||
text: HELLO_FINAL,
|
||||
}),
|
||||
);
|
||||
expect(harness.deletePreviewMessage).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Duplicate message regression tests ──────────────────────────────────
|
||||
// During final delivery, only ambiguous post-connect failures keep the
|
||||
// preview. Definite non-delivery falls back to a real send.
|
||||
|
||||
it("retains preview on ambiguous API error during final", async () => {
|
||||
it("clears unfinalized stream state before non-stream final delivery", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
// Plain Error with no error_code → ambiguous, prefer incomplete over duplicate
|
||||
harness.editPreview.mockRejectedValue(new Error("500: Internal Server Error"));
|
||||
|
||||
await expectFinalPreviewRetained({ harness });
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back when an archived preview edit target is missing and no alternate preview exists", async () => {
|
||||
const harness = createHarness();
|
||||
seedArchivedAnswerPreview(harness);
|
||||
harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found"));
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Complete final answer");
|
||||
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expectSendPayloadWith(harness, { text: "Complete final answer" });
|
||||
expect(result.kind).toBe("sent");
|
||||
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555);
|
||||
});
|
||||
|
||||
it("falls back when an archived preview ambiguous final edit would leave an incomplete prefix", async () => {
|
||||
const harness = createHarness();
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 5555,
|
||||
textSnapshot: "Hello fi",
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
harness.editPreview.mockRejectedValue(new Error("500: preview edit failed"));
|
||||
|
||||
await expectFinalEditFallbackToSend({
|
||||
harness,
|
||||
text: HELLO_FINAL,
|
||||
expectedLogSnippet: "preview is an incomplete prefix; falling back",
|
||||
});
|
||||
expect(harness.editPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: 5555,
|
||||
text: HELLO_FINAL,
|
||||
}),
|
||||
);
|
||||
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(5555);
|
||||
});
|
||||
|
||||
it("keeps the active preview when an archived final edit target is missing", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
seedArchivedAnswerPreview(harness);
|
||||
harness.editPreview.mockRejectedValue(new Error("400: Bad Request: message to edit not found"));
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Complete final answer");
|
||||
|
||||
expect(harness.editPreview).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("edit target missing; keeping alternate preview without fallback"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the archived preview when the final text regresses", async () => {
|
||||
const harness = createHarness();
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 5555,
|
||||
textSnapshot: "Recovered final answer.",
|
||||
deleteIfUnused: true,
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Recovered final answer");
|
||||
|
||||
expect(expectPreviewFinalized(result)).toEqual({
|
||||
content: "Recovered final answer.",
|
||||
messageId: 5555,
|
||||
});
|
||||
expect(harness.editPreview).not.toHaveBeenCalled();
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back on 4xx client rejection with error_code during final", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
const err = Object.assign(new Error("403: Forbidden"), { error_code: 403 });
|
||||
harness.editPreview.mockRejectedValue(err);
|
||||
|
||||
await expectFinalEditFallbackToSend({
|
||||
harness,
|
||||
text: "Hello final",
|
||||
expectedLogSnippet: "rejected by Telegram (client error); falling back",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains preview on 502 with error_code during final (ambiguous server error)", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
const err = Object.assign(new Error("502: Bad Gateway"), { error_code: 502 });
|
||||
harness.editPreview.mockRejectedValue(err);
|
||||
|
||||
await expectFinalPreviewRetained({
|
||||
harness,
|
||||
expectedLogSnippet: "ambiguous error; keeping existing preview to avoid duplicate",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when the first preview send may have landed without a message id", async () => {
|
||||
const stream = createTestDraftStream();
|
||||
stream.sendMayHaveLanded.mockReturnValue(true);
|
||||
const harness = createHarness({ answerStream: stream });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expectSendPayloadWith(harness, { text: HELLO_FINAL });
|
||||
});
|
||||
|
||||
it("retains when sendMayHaveLanded is true and a prior preview was visible", async () => {
|
||||
// Stream has a messageId (visible preview) but loses it after stop
|
||||
const stream = createTestDraftStream({ messageId: 999 });
|
||||
stream.sendMayHaveLanded.mockReturnValue(true);
|
||||
const harness = createHarness({
|
||||
answerStream: stream,
|
||||
answerHasStreamedMessage: true,
|
||||
});
|
||||
// Simulate messageId lost after stop (e.g. forceNewMessage or timeout)
|
||||
harness.stopDraftLane.mockImplementation(async (lane: DraftLaneState) => {
|
||||
stream.setMessageId(undefined);
|
||||
await lane.stream?.stop();
|
||||
});
|
||||
|
||||
await expectFinalPreviewRetained({
|
||||
harness,
|
||||
expectedLogSnippet: "preview send may have landed despite missing message id",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes consumed boundary previews after fallback final send", async () => {
|
||||
const harness = createHarness();
|
||||
harness.archivedAnswerPreviews.push({
|
||||
messageId: 4444,
|
||||
textSnapshot: "Boundary preview",
|
||||
deleteIfUnused: false,
|
||||
});
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Final with media",
|
||||
payload: { text: "Final with media", mediaUrl: "file:///tmp/example.png" },
|
||||
text: "photo",
|
||||
payload: { text: "photo", mediaUrl: "https://example.com/a.png" },
|
||||
infoKind: "final",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("sent");
|
||||
expectSendPayloadWith(harness, {
|
||||
text: "Final with media",
|
||||
mediaUrl: "file:///tmp/example.png",
|
||||
expect(harness.clearDraftLane).toHaveBeenCalledTimes(1);
|
||||
expect(harness.answer?.clear).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith(
|
||||
{
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
},
|
||||
{ durable: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("streams the first long final chunk and sends follow-up chunks", async () => {
|
||||
const harness = createHarness({
|
||||
answerMessageId: 999,
|
||||
draftMaxChars: 5,
|
||||
splitFinalTextForStream: () => ["Hello", " world", " again"],
|
||||
});
|
||||
expect(harness.deletePreviewMessage).toHaveBeenCalledWith(4444);
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Hello world again");
|
||||
|
||||
expect(expectPreviewFinalized(result)).toMatchObject({
|
||||
content: "Hello world again",
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.answer?.update).toHaveBeenCalledWith("Hello");
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(2);
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(1, { text: " world" });
|
||||
expect(harness.sendPayload).toHaveBeenNthCalledWith(2, { text: " again" });
|
||||
});
|
||||
|
||||
it("retains the streamed message when stop may have landed without a message id", async () => {
|
||||
const answer = createTestDraftStream();
|
||||
answer.sendMayHaveLanded.mockReturnValue(true);
|
||||
const harness = createHarness({ answerStream: answer });
|
||||
|
||||
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
|
||||
|
||||
expect(result.kind).toBe("preview-retained");
|
||||
expect(answer.update).toHaveBeenCalledWith(HELLO_FINAL);
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
expect(harness.lanes.answer.finalized).toBe(true);
|
||||
});
|
||||
|
||||
it("attaches buttons to the stream message without sending a second reply", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: HELLO_FINAL,
|
||||
payload: { text: HELLO_FINAL, channelData: { telegram: { buttons } } },
|
||||
infoKind: "final",
|
||||
buttons,
|
||||
});
|
||||
|
||||
expect(expectPreviewFinalized(result)).toMatchObject({
|
||||
content: HELLO_FINAL,
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.editStreamMessage).toHaveBeenCalledWith({
|
||||
laneName: "answer",
|
||||
messageId: 999,
|
||||
text: HELLO_FINAL,
|
||||
buttons,
|
||||
});
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the stream delivery when button attachment fails", async () => {
|
||||
const harness = createHarness({ answerMessageId: 999 });
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
harness.editStreamMessage.mockRejectedValue(new Error("400: button rejected"));
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: HELLO_FINAL,
|
||||
payload: { text: HELLO_FINAL, channelData: { telegram: { buttons } } },
|
||||
infoKind: "final",
|
||||
buttons,
|
||||
});
|
||||
|
||||
expect(expectPreviewFinalized(result)).toMatchObject({
|
||||
content: HELLO_FINAL,
|
||||
messageId: 999,
|
||||
});
|
||||
expect(harness.sendPayload).not.toHaveBeenCalled();
|
||||
expect(harness.log).toHaveBeenCalledWith(
|
||||
"telegram: answer stream button edit failed: Error: 400: button rejected",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user