
























@@ -13,6 +13,7 @@ type DispatchReplyWithBufferedBlockDispatcherArgs = Parameters<
1313>[0];
14141515const createTelegramDraftStream = vi.hoisted(() => vi.fn());
16+const createNativeTelegramToolProgressDraft = vi.hoisted(() => vi.fn());
1617const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() =>
1718vi.fn<(params: DispatchReplyWithBufferedBlockDispatcherArgs) => Promise<unknown>>(),
1819);
@@ -180,6 +181,8 @@ const telegramDepsForTest: TelegramBotDeps = {
180181wasSentByBot: wasSentByBot as TelegramBotDeps["wasSentByBot"],
181182createTelegramDraftStream:
182183createTelegramDraftStream as TelegramBotDeps["createTelegramDraftStream"],
184+createNativeTelegramToolProgressDraft:
185+createNativeTelegramToolProgressDraft as TelegramBotDeps["createNativeTelegramToolProgressDraft"],
183186deliverReplies: deliverReplies as TelegramBotDeps["deliverReplies"],
184187deliverInboundReplyWithMessageSendContext:
185188deliverInboundReplyWithMessageSendContext as TelegramBotDeps["deliverInboundReplyWithMessageSendContext"],
@@ -199,6 +202,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
199202beforeEach(() => {
200203resetTelegramReplyFenceForTests();
201204createTelegramDraftStream.mockReset();
205+createNativeTelegramToolProgressDraft.mockReset();
202206dispatchReplyWithBufferedBlockDispatcher.mockReset();
203207deliverReplies.mockReset();
204208deliverInboundReplyWithMessageSendContext.mockReset();
@@ -295,6 +299,10 @@ describe("dispatchTelegramMessage draft streaming", () => {
295299const createDraftStream = (messageId?: number) => createTestDraftStream({ messageId });
296300const createSequencedDraftStream = (startMessageId = 1001) =>
297301createSequencedTestDraftStream(startMessageId);
302+const createNativeToolProgressDraft = (updateResult = true) => ({
303+update: vi.fn(async () => updateResult),
304+stop: vi.fn(),
305+});
298306299307function setupDraftStreams(params?: { answerMessageId?: number; reasoningMessageId?: number }) {
300308const answerDraftStream = createDraftStream(params?.answerMessageId);
@@ -1226,6 +1234,185 @@ describe("dispatchTelegramMessage draft streaming", () => {
12261234expect(deliverReplies).not.toHaveBeenCalled();
12271235});
122812361237+it("uses native DM drafts for transient tool progress before answer text", async () => {
1238+const nativeDraft = createNativeToolProgressDraft();
1239+createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
1240+const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
1241+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
1242+async ({ dispatcherOptions, replyOptions }) => {
1243+await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
1244+await replyOptions?.onPartialReply?.({ text: "Done ", delta: "Done " });
1245+await dispatcherOptions.deliver({ text: "Done answer." }, { kind: "final" });
1246+return { queuedFinal: true };
1247+},
1248+);
1249+1250+await dispatchWithContext({
1251+context: createContext(),
1252+streamMode: "partial",
1253+telegramCfg: { streaming: { mode: "partial" } },
1254+});
1255+1256+expect(createNativeTelegramToolProgressDraft).toHaveBeenCalledWith(
1257+expect.objectContaining({
1258+chatId: 123,
1259+thread: { id: 777, scope: "dm" },
1260+}),
1261+);
1262+expect(nativeDraft.update).toHaveBeenCalledWith(expect.stringContaining("Exec"));
1263+expect(nativeDraft.update).toHaveBeenCalledWith(expect.not.stringContaining("`"));
1264+expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Done ");
1265+expect(answerDraftStream.update).toHaveBeenLastCalledWith("Done answer.");
1266+expect(answerDraftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Exec"));
1267+expect(nativeDraft.stop).toHaveBeenCalled();
1268+});
1269+1270+it("falls back to edited preview tool progress when native DM draft update fails", async () => {
1271+const nativeDraft = createNativeToolProgressDraft(false);
1272+createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
1273+const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
1274+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
1275+async ({ dispatcherOptions, replyOptions }) => {
1276+await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
1277+await dispatcherOptions.deliver({ text: "Done answer." }, { kind: "final" });
1278+return { queuedFinal: true };
1279+},
1280+);
1281+1282+await dispatchWithContext({
1283+context: createContext(),
1284+streamMode: "partial",
1285+telegramCfg: { streaming: { mode: "partial" } },
1286+});
1287+1288+expect(nativeDraft.update).toHaveBeenCalledWith(expect.stringContaining("Exec"));
1289+expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, expect.stringContaining("Exec"));
1290+expect(answerDraftStream.update).toHaveBeenLastCalledWith("Done answer.");
1291+});
1292+1293+it("does not hide durable tool media in native DM drafts", async () => {
1294+const nativeDraft = createNativeToolProgressDraft();
1295+createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
1296+setupDraftStreams({ answerMessageId: 2001 });
1297+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
1298+await dispatcherOptions.deliver(
1299+{ text: "Rendered chart", mediaUrl: "/tmp/chart.png" },
1300+{ kind: "tool" },
1301+);
1302+return { queuedFinal: true };
1303+});
1304+1305+await dispatchWithContext({
1306+context: createContext(),
1307+streamMode: "partial",
1308+telegramCfg: { streaming: { mode: "partial" } },
1309+});
1310+1311+expect(nativeDraft.update).not.toHaveBeenCalled();
1312+expectDeliveredReply(0, { text: "Rendered chart", mediaUrl: "/tmp/chart.png" });
1313+});
1314+1315+it("does not hide durable tool errors in native DM drafts", async () => {
1316+const nativeDraft = createNativeToolProgressDraft();
1317+createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
1318+setupDraftStreams({ answerMessageId: 2001 });
1319+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
1320+await dispatcherOptions.deliver({ text: "Tool failed", isError: true }, { kind: "tool" });
1321+return { queuedFinal: true };
1322+});
1323+1324+await dispatchWithContext({
1325+context: createContext(),
1326+streamMode: "partial",
1327+telegramCfg: { streaming: { mode: "partial" } },
1328+});
1329+1330+expect(nativeDraft.update).not.toHaveBeenCalled();
1331+expectDeliveredReply(0, { text: "Tool failed", isError: true });
1332+});
1333+1334+it("does not hide exec approval payloads in native DM drafts", async () => {
1335+const nativeDraft = createNativeToolProgressDraft();
1336+createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
1337+const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
1338+const execApproval = { id: "approval-1", command: "pnpm test" };
1339+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
1340+await dispatcherOptions.deliver(
1341+{
1342+text: "Approve command?",
1343+channelData: { execApproval },
1344+},
1345+{ kind: "tool" },
1346+);
1347+return { queuedFinal: true };
1348+});
1349+1350+await dispatchWithContext({
1351+context: createContext(),
1352+streamMode: "partial",
1353+telegramCfg: { streaming: { mode: "partial" } },
1354+});
1355+1356+expect(nativeDraft.update).not.toHaveBeenCalled();
1357+expect(answerDraftStream.update).toHaveBeenCalledWith("Approve command?");
1358+});
1359+1360+it("does not use native tool progress drafts in groups", async () => {
1361+setupDraftStreams({ answerMessageId: 2001 });
1362+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
1363+async ({ dispatcherOptions, replyOptions }) => {
1364+await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
1365+await dispatcherOptions.deliver({ text: "Done answer." }, { kind: "final" });
1366+return { queuedFinal: true };
1367+},
1368+);
1369+1370+await dispatchWithContext({
1371+context: createContext({
1372+ctxPayload: {
1373+SessionKey: "agent:main:telegram:group:-100123",
1374+ChatType: "group",
1375+} as unknown as TelegramMessageContext["ctxPayload"],
1376+msg: {
1377+chat: { id: -100123, type: "supergroup" },
1378+message_id: 99,
1379+} as unknown as TelegramMessageContext["msg"],
1380+chatId: -100123,
1381+isGroup: true,
1382+threadSpec: { id: undefined, scope: "none" },
1383+}),
1384+streamMode: "partial",
1385+telegramCfg: { streaming: { mode: "partial" } },
1386+});
1387+1388+expect(createNativeTelegramToolProgressDraft).not.toHaveBeenCalled();
1389+});
1390+1391+it("does not hide text-only tool output after answer streaming starts", async () => {
1392+const nativeDraft = createNativeToolProgressDraft();
1393+createNativeTelegramToolProgressDraft.mockReturnValue(nativeDraft);
1394+const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
1395+dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
1396+async ({ dispatcherOptions, replyOptions }) => {
1397+await replyOptions?.onPartialReply?.({ text: "Partial answer" });
1398+await dispatcherOptions.deliver({ text: "Tool result after partial" }, { kind: "tool" });
1399+return { queuedFinal: true };
1400+},
1401+);
1402+1403+await dispatchWithContext({
1404+context: createContext(),
1405+streamMode: "partial",
1406+telegramCfg: { streaming: { mode: "partial" } },
1407+});
1408+1409+expect(nativeDraft.update).not.toHaveBeenCalledWith(
1410+expect.stringContaining("Tool result after partial"),
1411+);
1412+expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Partial answer");
1413+expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Tool result after partial");
1414+});
1415+12291416it("rotates the answer stream only after a finalized assistant message", async () => {
12301417const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
12311418dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。