mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:40:43 +00:00
test(telegram): cover message-only previews
This commit is contained in:
@@ -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_" })],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user