test(telegram): cover message-only previews

This commit is contained in:
Ayaan Zaidi
2026-04-30 17:49:13 +05:30
parent 2a4dd89253
commit c9d9067931
5 changed files with 27 additions and 423 deletions

View File

@@ -747,31 +747,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("does not materialize native draft tool progress before final-only text", async () => {
const draftStream = createTestDraftStream({ previewMode: "draft" });
draftStream.materialize.mockResolvedValue(321);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async ({ dispatcherOptions, replyOptions }) => {
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await dispatcherOptions.deliver({ text: "Done" }, { kind: "final" });
return { queuedFinal: true };
},
);
await dispatchWithContext({ context: createContext(), streamMode: "partial" });
expect(draftStream.update).toHaveBeenCalledWith("Working…\n• `tool: exec`");
expect(draftStream.update).not.toHaveBeenCalledWith("Done");
expect(draftStream.materialize).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Done" })],
}),
);
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("suppresses Telegram tool progress when explicitly disabled", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);
@@ -2463,13 +2438,11 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
thread: { id: 777, scope: "dm" },
previewTransport: "message",
}),
);
expect(createTelegramDraftStream.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
thread: { id: 777, scope: "dm" },
previewTransport: "message",
}),
);
});
@@ -2494,7 +2467,6 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(createTelegramDraftStream.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
thread: { id: 777, scope: "dm" },
previewTransport: "message",
}),
);
expect(answerDraftStream.materialize).not.toHaveBeenCalled();
@@ -2638,14 +2610,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("keeps DM draft reasoning block updates in preview flow without sending duplicates", async () => {
it("keeps DM reasoning block updates in preview flow without sending duplicates", async () => {
const answerDraftStream = createDraftStream(999);
let previewRevision = 0;
const reasoningDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(true),
messageId: vi.fn().mockReturnValue(undefined),
previewMode: vi.fn().mockReturnValue("draft"),
messageId: vi.fn().mockReturnValue(111),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -2680,10 +2651,16 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" });
expect(editMessageTelegram).toHaveBeenCalledWith(123, 999, "3", expect.any(Object));
expect(reasoningDraftStream.update).toHaveBeenCalledWith(
expect(editMessageTelegram).toHaveBeenCalledWith(
123,
111,
"Reasoning:\nI am counting letters. The total is 3.",
expect.any(Object),
);
expect(reasoningDraftStream.flush).toHaveBeenCalled();
expect(reasoningDraftStream.update).toHaveBeenCalledWith(
"Reasoning:\nI am counting letters...",
);
expect(reasoningDraftStream.flush).not.toHaveBeenCalled();
expect(deliverReplies).not.toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: expect.stringContaining("Reasoning:\nI am") })],
@@ -2691,14 +2668,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
);
});
it("falls back to normal send when DM draft reasoning flush emits no preview update", async () => {
it("falls back to normal send when DM reasoning preview has no message id", async () => {
const answerDraftStream = createDraftStream(999);
const previewRevision = 0;
const reasoningDraftStream = {
update: vi.fn(),
flush: vi.fn().mockResolvedValue(false),
messageId: vi.fn().mockReturnValue(undefined),
previewMode: vi.fn().mockReturnValue("draft"),
previewRevision: vi.fn().mockReturnValue(previewRevision),
clear: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -2722,7 +2698,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
await dispatchWithContext({ context: createReasoningStreamContext(), streamMode: "partial" });
expect(reasoningDraftStream.flush).toHaveBeenCalled();
expect(reasoningDraftStream.flush).not.toHaveBeenCalled();
expect(deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Reasoning:\n_step one expanded_" })],

View File

@@ -271,7 +271,6 @@ const grammySpies = vi.hoisted(() => ({
sendChatActionSpy: vi.fn(),
editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
editMessageReplyMarkupSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock,
sendMessageDraftSpy: vi.fn(async () => true) as AnyAsyncMock,
setMessageReactionSpy: vi.fn(async () => undefined) as AnyAsyncMock,
setMyCommandsSpy: vi.fn(async () => undefined) as AnyAsyncMock,
getMeSpy: vi.fn(async () => ({
@@ -297,7 +296,6 @@ export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQu
export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy;
export const editMessageTextSpy: AnyAsyncMock = grammySpies.editMessageTextSpy;
export const editMessageReplyMarkupSpy: AnyAsyncMock = grammySpies.editMessageReplyMarkupSpy;
export const sendMessageDraftSpy: AnyAsyncMock = grammySpies.sendMessageDraftSpy;
export const setMessageReactionSpy: AnyAsyncMock = grammySpies.setMessageReactionSpy;
export const setMyCommandsSpy: AnyAsyncMock = grammySpies.setMyCommandsSpy;
export const getMeSpy: AnyAsyncMock = grammySpies.getMeSpy;
@@ -327,7 +325,6 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = {
sendChatAction: grammySpies.sendChatActionSpy,
editMessageText: grammySpies.editMessageTextSpy,
editMessageReplyMarkup: grammySpies.editMessageReplyMarkupSpy,
sendMessageDraft: grammySpies.sendMessageDraftSpy,
setMessageReaction: grammySpies.setMessageReactionSpy,
setMyCommands: grammySpies.setMyCommandsSpy,
getMe: grammySpies.getMeSpy,
@@ -521,8 +518,6 @@ beforeEach(() => {
editMessageTextSpy.mockResolvedValue({ message_id: 88 });
editMessageReplyMarkupSpy.mockReset();
editMessageReplyMarkupSpy.mockResolvedValue({ message_id: 88 });
sendMessageDraftSpy.mockReset();
sendMessageDraftSpy.mockResolvedValue(true);
enqueueSystemEventSpy.mockReset();
wasSentByBot.mockReset();
wasSentByBot.mockReturnValue(false);

View File

@@ -1,13 +1,10 @@
import { vi } from "vitest";
type DraftPreviewMode = "message" | "draft";
export type TestDraftStream = {
update: ReturnType<typeof vi.fn<(text: string) => void>>;
flush: ReturnType<typeof vi.fn<() => Promise<void>>>;
messageId: ReturnType<typeof vi.fn<() => number | undefined>>;
visibleSinceMs: ReturnType<typeof vi.fn<() => number | undefined>>;
previewMode: ReturnType<typeof vi.fn<() => DraftPreviewMode>>;
previewRevision: ReturnType<typeof vi.fn<() => number>>;
lastDeliveredText: ReturnType<typeof vi.fn<() => string>>;
clear: ReturnType<typeof vi.fn<() => Promise<void>>>;
@@ -21,7 +18,6 @@ export type TestDraftStream = {
export function createTestDraftStream(params?: {
messageId?: number;
previewMode?: DraftPreviewMode;
onUpdate?: (text: string) => void;
onStop?: () => void | Promise<void>;
onDiscard?: () => void | Promise<void>;
@@ -41,7 +37,6 @@ export function createTestDraftStream(params?: {
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => messageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
previewMode: vi.fn().mockReturnValue(params?.previewMode ?? "message"),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
lastDeliveredText: vi.fn().mockImplementation(() => lastDeliveredText),
clear: vi.fn().mockResolvedValue(undefined),
@@ -84,7 +79,6 @@ export function createSequencedTestDraftStream(startMessageId = 1001): TestDraft
flush: vi.fn().mockResolvedValue(undefined),
messageId: vi.fn().mockImplementation(() => activeMessageId),
visibleSinceMs: vi.fn().mockImplementation(() => visibleSinceMs),
previewMode: vi.fn().mockReturnValue("message"),
previewRevision: vi.fn().mockImplementation(() => previewRevision),
lastDeliveredText: vi.fn().mockImplementation(() => lastDeliveredText),
clear: vi.fn().mockResolvedValue(undefined),

View File

@@ -1,14 +1,12 @@
import type { Bot } from "grammy";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __testing, createTelegramDraftStream } from "./draft-stream.js";
import { createTelegramDraftStream } from "./draft-stream.js";
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
function createMockDraftApi(sendMessageImpl?: () => Promise<{ message_id: number }>) {
return {
sendMessage: vi.fn(sendMessageImpl ?? (async () => ({ message_id: 17 }))),
sendMessageDraft: vi.fn().mockResolvedValue(true),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
@@ -53,22 +51,6 @@ function expectDmMessagePreviewViaSendMessage(
expect(api.editMessageText).not.toHaveBeenCalled();
}
async function createDmDraftTransportStream(params: {
api?: ReturnType<typeof createMockDraftApi>;
previewTransport?: "draft" | "message";
warn?: (message: string) => void;
}) {
const api = params.api ?? createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: params.previewTransport ?? "draft",
...(params.warn ? { warn: params.warn } : {}),
});
stream.update("Hello");
await stream.flush();
return { api, stream };
}
function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
const api = createMockDraftApi();
api.sendMessage
@@ -82,10 +64,6 @@ function createForceNewMessageHarness(params: { throttleMs?: number } = {}) {
}
describe("createTelegramDraftStream", () => {
afterEach(() => {
__testing.resetTelegramDraftStreamForTests();
});
it("sends stream preview message with message_thread_id when provided", async () => {
const api = createMockDraftApi();
const stream = createForumDraftStream(api);
@@ -137,31 +115,20 @@ describe("createTelegramDraftStream", () => {
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", undefined));
});
it("uses sendMessageDraft for dm threads and does not create a preview message", async () => {
it("uses sendMessage/editMessageText for dm thread previews", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
stream.update("Hello");
await vi.waitFor(() =>
expect(api.sendMessageDraft).toHaveBeenCalledWith(123, expect.any(Number), "Hello", {
message_thread_id: 42,
}),
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 }),
);
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
await stream.clear();
expect(api.sendMessageDraft).toHaveBeenLastCalledWith(123, expect.any(Number), "", {
message_thread_id: 42,
});
expect(api.deleteMessage).not.toHaveBeenCalled();
});
stream.update("Hello again");
await stream.flush();
it("supports forcing message transport in dm threads", async () => {
const { api } = await createDmDraftTransportStream({ previewTransport: "message" });
expectDmMessagePreviewViaSendMessage(api);
expect(api.sendMessageDraft).not.toHaveBeenCalled();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("tracks when a message preview first became visible", async () => {
@@ -169,7 +136,7 @@ describe("createTelegramDraftStream", () => {
try {
vi.setSystemTime(new Date("2026-04-26T01:00:00.000Z"));
const api = createMockDraftApi();
const stream = createDraftStream(api, { previewTransport: "message" });
const stream = createDraftStream(api);
stream.update("Hello");
await stream.flush();
@@ -186,41 +153,6 @@ describe("createTelegramDraftStream", () => {
}
});
it("falls back to message transport when sendMessageDraft is unavailable", async () => {
const api = createMockDraftApi();
delete (api as { sendMessageDraft?: unknown }).sendMessageDraft;
const warn = vi.fn();
await createDmDraftTransportStream({ api, warn });
expectDmMessagePreviewViaSendMessage(api);
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft unavailable; falling back to sendMessage/editMessageText",
);
});
it("falls back to message transport when sendMessageDraft is rejected at runtime", async () => {
const api = createMockDraftApi();
api.sendMessageDraft.mockRejectedValueOnce(
new Error(
"Call to 'sendMessageDraft' failed! (400: Bad Request: method sendMessageDraft can be used only in private chats)",
),
);
const warn = vi.fn();
const { stream } = await createDmDraftTransportStream({ api, warn });
expect(api.sendMessageDraft).toHaveBeenCalledTimes(1);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
expect(stream.previewMode?.()).toBe("message");
expect(warn).toHaveBeenCalledWith(
"telegram stream preview: sendMessageDraft rejected by API; falling back to sendMessage/editMessageText",
);
stream.update("Hello again");
await stream.flush();
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
});
it("retries DM message preview send without thread when thread is not found", async () => {
const api = createMockDraftApi();
api.sendMessage
@@ -229,7 +161,6 @@ describe("createTelegramDraftStream", () => {
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
warn,
});
@@ -247,7 +178,6 @@ describe("createTelegramDraftStream", () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
replyToMessageId: 411,
});
@@ -261,11 +191,10 @@ describe("createTelegramDraftStream", () => {
});
});
it("materializes draft previews using rendered HTML text", async () => {
it("materializes message previews using rendered HTML text", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
renderText: (text) => ({
text: text.replace("**bold**", "<b>bold</b>"),
parseMode: "HTML",
@@ -274,68 +203,20 @@ describe("createTelegramDraftStream", () => {
stream.update("**bold**");
await stream.flush();
await stream.materialize?.();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledWith(123, "<b>bold</b>", {
message_thread_id: 42,
parse_mode: "HTML",
});
});
it("clears draft after materializing to avoid duplicate display in DM", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(17);
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
// Draft should be cleared with empty string after real message is sent.
const draftCalls = api.sendMessageDraft.mock.calls;
const clearCall = draftCalls.find((call) => call[2] === "");
expect(clearCall).toBeDefined();
expect(clearCall?.[0]).toBe(123);
expect(clearCall?.[3]).toEqual({ message_thread_id: 42 });
});
it("retries materialize send without thread when dm thread lookup fails", async () => {
const api = createMockDraftApi();
api.sendMessage
.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found"))
.mockResolvedValueOnce({ message_id: 55 });
const warn = vi.fn();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
warn,
});
stream.update("Hello");
await stream.flush();
const materializedId = await stream.materialize?.();
expect(materializedId).toBe(55);
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello", { message_thread_id: 42 });
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Hello", undefined);
const draftCalls = api.sendMessageDraft.mock.calls;
const clearCall = draftCalls.find((call) => call[2] === "");
expect(clearCall).toBeDefined();
expect(clearCall?.[3]).toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"telegram stream preview materialize send failed with message_thread_id, retrying without thread",
);
expect(api.sendMessage).toHaveBeenCalledTimes(1);
});
it("returns existing preview id when materializing message transport", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, {
thread: { id: 42, scope: "dm" },
previewTransport: "message",
});
stream.update("Hello");
@@ -346,7 +227,7 @@ describe("createTelegramDraftStream", () => {
expect(api.sendMessage).toHaveBeenCalledTimes(1);
});
it("does not edit or delete messages after DM draft stream finalization", async () => {
it("deletes message preview on clear after finalization", async () => {
const api = createMockDraftApi();
const stream = createThreadedDraftStream(api, { id: 42, scope: "dm" });
@@ -356,86 +237,9 @@ describe("createTelegramDraftStream", () => {
await stream.stop();
await stream.clear();
expect(api.sendMessageDraft).toHaveBeenCalled();
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
expect(api.deleteMessage).not.toHaveBeenCalled();
});
it("rotates draft_id when forceNewMessage races an in-flight DM draft send", async () => {
let resolveFirstDraft: ((value: boolean) => void) | undefined;
const firstDraftSend = new Promise<boolean>((resolve) => {
resolveFirstDraft = resolve;
});
const api = {
sendMessageDraft: vi.fn().mockReturnValueOnce(firstDraftSend).mockResolvedValueOnce(true),
sendMessage: vi.fn().mockResolvedValue({ message_id: 17 }),
editMessageText: vi.fn().mockResolvedValue(true),
deleteMessage: vi.fn().mockResolvedValue(true),
};
const stream = createThreadedDraftStream(
api as unknown as ReturnType<typeof createMockDraftApi>,
{ id: 42, scope: "dm" },
);
stream.update("Message A");
await vi.waitFor(() => expect(api.sendMessageDraft).toHaveBeenCalledTimes(1));
stream.forceNewMessage();
stream.update("Message B");
resolveFirstDraft?.(true);
await stream.flush();
expect(api.sendMessageDraft).toHaveBeenCalledTimes(2);
const firstDraftId = api.sendMessageDraft.mock.calls[0]?.[1];
const secondDraftId = api.sendMessageDraft.mock.calls[1]?.[1];
expect(typeof firstDraftId).toBe("number");
expect(typeof secondDraftId).toBe("number");
expect(firstDraftId).not.toBe(secondDraftId);
expect(api.sendMessageDraft.mock.calls[1]?.[2]).toBe("Message B");
expect(api.sendMessage).not.toHaveBeenCalled();
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("shares draft-id allocation across distinct module instances", async () => {
const draftA = await importFreshModule<typeof import("./draft-stream.js")>(
import.meta.url,
"./draft-stream.js?scope=shared-a",
);
const draftB = await importFreshModule<typeof import("./draft-stream.js")>(
import.meta.url,
"./draft-stream.js?scope=shared-b",
);
const apiA = createMockDraftApi();
const apiB = createMockDraftApi();
draftA.__testing.resetTelegramDraftStreamForTests();
try {
const streamA = draftA.createTelegramDraftStream({
api: apiA as unknown as Bot["api"],
chatId: 123,
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
const streamB = draftB.createTelegramDraftStream({
api: apiB as unknown as Bot["api"],
chatId: 123,
thread: { id: 42, scope: "dm" },
previewTransport: "draft",
});
streamA.update("Message A");
await streamA.flush();
streamB.update("Message B");
await streamB.flush();
expect(apiA.sendMessageDraft.mock.calls[0]?.[1]).toBe(1);
expect(apiB.sendMessageDraft.mock.calls[0]?.[1]).toBe(2);
} finally {
draftA.__testing.resetTelegramDraftStreamForTests();
}
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
expect(api.editMessageText).toHaveBeenCalledWith(123, 17, "Hello again");
expect(api.deleteMessage).toHaveBeenCalledWith(123, 17);
});
it("creates new message after forceNewMessage is called", async () => {

View File

@@ -493,171 +493,6 @@ describe("createLaneTextDeliverer", () => {
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("materializes DM draft streaming final even when text is unchanged", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft", messageId: 321 });
answerStream.materialize.mockResolvedValue(321);
answerStream.update.mockImplementation(() => {});
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Hello final",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Hello final",
payload: { text: "Hello final" },
infoKind: "final",
});
expect(expectPreviewFinalized(result)).toEqual({ content: "Hello final", messageId: 321 });
expect(harness.flushDraftLane).toHaveBeenCalled();
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("does not materialize a native draft for final-only text", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
answerStream.materialize.mockResolvedValue(321);
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.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Final only" }),
);
});
it("does not materialize native draft tool-progress preview before final-only text", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
answerStream.materialize.mockResolvedValue(321);
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: false,
answerLastPartialText: "Working...\n- tool: exec",
});
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.toHaveBeenCalledWith("Final only");
expect(answerStream.materialize).not.toHaveBeenCalled();
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Final only" }),
);
});
it("materializes DM draft streaming final when revision changes", async () => {
let previewRevision = 3;
const answerStream = createTestDraftStream({ previewMode: "draft", messageId: 654 });
answerStream.materialize.mockResolvedValue(654);
answerStream.previewRevision.mockImplementation(() => previewRevision);
answerStream.update.mockImplementation(() => {});
answerStream.flush.mockImplementation(async () => {
previewRevision += 1;
});
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Final answer",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Final answer",
payload: { text: "Final answer" },
infoKind: "final",
});
expect(expectPreviewFinalized(result)).toEqual({ content: "Final answer", messageId: 654 });
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).not.toHaveBeenCalled();
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
});
it("falls back to normal send when draft materialize returns no message id", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
answerStream.materialize.mockResolvedValue(undefined);
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Hello final",
});
const result = await deliverFinalAnswer(harness, HELLO_FINAL);
expect(result.kind).toBe("sent");
expect(answerStream.materialize).toHaveBeenCalledTimes(1);
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: HELLO_FINAL }),
);
expect(harness.log).toHaveBeenCalledWith(
expect.stringContaining("draft preview materialize produced no message id"),
);
});
it("does not use DM draft final shortcut for media payloads", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Image incoming",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Image incoming",
payload: { text: "Image incoming", mediaUrl: "file:///tmp/example.png" },
infoKind: "final",
});
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Image incoming", mediaUrl: "file:///tmp/example.png" }),
);
expect(harness.markDelivered).not.toHaveBeenCalled();
});
it("does not use DM draft final shortcut when inline buttons are present", async () => {
const answerStream = createTestDraftStream({ previewMode: "draft" });
const harness = createHarness({
answerStream: answerStream as DraftLaneState["stream"],
answerHasStreamedMessage: true,
answerLastPartialText: "Choose one",
});
const result = await harness.deliverLaneText({
laneName: "answer",
text: "Choose one",
payload: { text: "Choose one" },
previewButtons: [[{ text: "OK", callback_data: "ok" }]],
infoKind: "final",
});
expect(result.kind).toBe("sent");
expect(harness.sendPayload).toHaveBeenCalledWith(
expect.objectContaining({ text: "Choose one" }),
);
expect(harness.markDelivered).not.toHaveBeenCalled();
});
// ── Duplicate message regression tests ──────────────────────────────────
// During final delivery, only ambiguous post-connect failures keep the
// preview. Definite non-delivery falls back to a real send.