test(telegram): move preview-finalization cases to lane unit tests

This commit is contained in:
Peter Steinberger
2026-03-03 02:31:54 +00:00
parent 1929151103
commit 57336203d5
2 changed files with 218 additions and 222 deletions

View File

@@ -342,166 +342,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(loadSessionStore).toHaveBeenCalledWith("/tmp/sessions.json", { skipCache: true });
});
it("finalizes text-only replies by editing the preview message in place", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Hel" });
await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.clear).not.toHaveBeenCalled();
expect(draftStream.stop).toHaveBeenCalled();
});
it("edits the preview message created during stop() final flush", async () => {
let messageId: number | undefined;
const draftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
messageId = 777;
}),
forceNewMessage: vi.fn(),
};
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "777" });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.stop).toHaveBeenCalled();
});
it("primes stop() with final text when pending partial is below initial threshold", async () => {
let answerMessageId: number | undefined;
const answerDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => answerMessageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
answerMessageId = 777;
}),
forceNewMessage: vi.fn(),
};
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "no" });
await dispatcherOptions.deliver({ text: "no problem" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "777" });
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenCalledWith("no");
expect(answerDraftStream.update).toHaveBeenLastCalledWith("no problem");
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "no problem", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(answerDraftStream.stop).toHaveBeenCalled();
});
it("does not duplicate final delivery when stop-created preview edit fails", async () => {
let messageId: number | undefined;
const draftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
messageId = 777;
}),
forceNewMessage: vi.fn(),
};
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockRejectedValue(new Error("500: edit failed after stop flush"));
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 777, "Short final", expect.any(Object));
expect(deliverReplies).not.toHaveBeenCalled();
expect(draftStream.stop).toHaveBeenCalled();
});
it("falls back to normal delivery when existing preview edit fails", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({ text: "Hel" });
await dispatcherOptions.deliver({ text: "Hello final" }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockRejectedValue(new Error("500: preview edit failed"));
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "Hello final", expect.any(Object));
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Hello final" })],
}),
);
});
it("falls back to normal delivery when stop-created preview has no message id", async () => {
const draftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockReturnValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
forceNewMessage: vi.fn(),
};
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Short final" }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Short final" })],
}),
);
expect(draftStream.stop).toHaveBeenCalled();
});
it("does not overwrite finalized preview when additional final payloads are sent", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -565,29 +405,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.stop).toHaveBeenCalled();
});
it("falls back to normal delivery when preview final is too long to edit", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
const longText = "x".repeat(5000);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: longText }, { kind: "final" });
return { queuedFinal: true };
});
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
await dispatchWithContext({ context: createContext() });
expect(editMessageTelegram).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: longText })],
}),
);
expect(draftStream.clear).toHaveBeenCalledTimes(1);
expect(draftStream.stop).toHaveBeenCalled();
});
it.each([
{ label: "default account config", telegramCfg: {} },
{ label: "account blockStreaming override", telegramCfg: { blockStreaming: true } },
@@ -1447,45 +1264,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(deliverReplies).not.toHaveBeenCalled();
});
it("edits stop-created preview when final text is shorter than buffered draft", async () => {
let answerMessageId: number | undefined;
const answerDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => answerMessageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockImplementation(async () => {
answerMessageId = 999;
}),
forceNewMessage: vi.fn(),
};
const reasoningDraftStream = createDraftStream();
createTelegramDraftStream
.mockImplementationOnce(() => answerDraftStream)
.mockImplementationOnce(() => reasoningDraftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onPartialReply?.({
text: "Let me check that file and confirm details for you.",
});
await dispatcherOptions.deliver({ text: "Let me check that file." }, { kind: "final" });
return { queuedFinal: true };
},
);
deliverReplies.mockResolvedValue({ delivered: true });
editMessageTelegram.mockResolvedValue({ ok: true, chatId: "123", messageId: "999" });
await dispatchWithContext({ context: createContext(), streamMode: "block" });
expect(editMessageTelegram).toHaveBeenCalledWith(
123,
999,
"Let me check that file.",
expect.any(Object),
);
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not edit preview message when final payload is an error", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -0,0 +1,218 @@
import { describe, expect, it, vi } from "vitest";
import type { ReplyPayload } from "../auto-reply/types.js";
import { createLaneTextDeliverer, type DraftLaneState, type LaneName } from "./lane-delivery.js";
type MockStreamState = {
stream: NonNullable<DraftLaneState["stream"]>;
setMessageId: (value: number | undefined) => void;
};
function createMockStream(initialMessageId?: number): MockStreamState {
let messageId = initialMessageId;
const stream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
forceNewMessage: vi.fn(),
previewMode: vi.fn().mockReturnValue("message"),
previewRevision: vi.fn().mockReturnValue(0),
} as unknown as NonNullable<DraftLaneState["stream"]>;
return {
stream,
setMessageId: (value) => {
messageId = value;
},
};
}
function createHarness(params?: {
answerMessageId?: number;
draftMaxChars?: number;
answerMessageIdAfterStop?: number;
}) {
const answer = createMockStream(params?.answerMessageId);
const reasoning = createMockStream();
const lanes: Record<LaneName, DraftLaneState> = {
answer: { stream: answer.stream, lastPartialText: "", hasStreamedMessage: false },
reasoning: { stream: reasoning.stream, lastPartialText: "", hasStreamedMessage: false },
};
const sendPayload = vi.fn().mockResolvedValue(true);
const flushDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => {
await lane.stream?.flush();
});
const stopDraftLane = vi.fn().mockImplementation(async (lane: DraftLaneState) => {
if (lane === lanes.answer && params?.answerMessageIdAfterStop !== undefined) {
answer.setMessageId(params.answerMessageIdAfterStop);
}
await lane.stream?.stop();
});
const editPreview = vi.fn().mockResolvedValue(undefined);
const deletePreviewMessage = vi.fn().mockResolvedValue(undefined);
const log = vi.fn();
const markDelivered = vi.fn();
const finalizedPreviewByLane: Record<LaneName, boolean> = { answer: false, reasoning: false };
const archivedAnswerPreviews: Array<{ messageId: number; textSnapshot: string }> = [];
const deliverLaneText = createLaneTextDeliverer({
lanes,
archivedAnswerPreviews,
finalizedPreviewByLane,
draftMaxChars: params?.draftMaxChars ?? 4_096,
applyTextToPayload: (payload: ReplyPayload, text: string) => ({ ...payload, text }),
sendPayload,
flushDraftLane,
stopDraftLane,
editPreview,
deletePreviewMessage,
log,
markDelivered,
});
return {
deliverLaneText,
lanes,
answer,
sendPayload,
flushDraftLane,
stopDraftLane,
editPreview,
log,
markDelivered,
};
}
describe("createLaneTextDeliverer", () => {
it("finalizes text-only replies by editing an existing preview message", async () => {
const harness = createHarness({ answerMessageId: 999 });
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello final",
payload: { text: "Hello final" },
infoKind: "final",
});
expect(result).toBe("preview-finalized");
expect(harness.editPreview).toHaveBeenCalledWith(
expect.objectContaining({
laneName: "answer",
messageId: 999,
text: "Hello final",
context: "final",
}),
);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.stopDraftLane).toHaveBeenCalledTimes(1);
});
it("primes stop-created previews with final text before editing", async () => {
const harness = createHarness({ answerMessageIdAfterStop: 777 });
harness.lanes.answer.lastPartialText = "no";
const result = await harness.deliverLaneText({
laneName: "answer",
text: "no problem",
payload: { text: "no problem" },
infoKind: "final",
});
expect(result).toBe("preview-finalized");
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("treats stop-created preview edit failures as delivered", async () => {
const harness = createHarness({ answerMessageIdAfterStop: 777 });
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).toBe("preview-finalized");
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("treating as delivered"));
});
it("falls back to normal delivery when editing an existing preview fails", async () => {
const harness = createHarness({ answerMessageId: 999 });
harness.editPreview.mockRejectedValue(new Error("500: preview edit failed"));
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello final",
payload: { text: "Hello final" },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.editPreview).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Hello final" }),
);
});
it("falls back to normal delivery when stop-created preview has no message id", async () => {
const harness = createHarness();
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Short final",
payload: { text: "Short final" },
infoKind: "final",
});
expect(result).toBe("sent");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Short final" }),
);
});
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(result).toBe("preview-finalized");
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).toBe("sent");
expect(harness.editPreview).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(expect.objectContaining({ text: longText }));
expect(harness.log).toHaveBeenCalledWith(expect.stringContaining("preview final too long"));
});
});