diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts index e3415e9e0..97e5a918f 100644 --- a/src/app/stores/settings-store.ts +++ b/src/app/stores/settings-store.ts @@ -192,6 +192,15 @@ export function setCompactOutputMode(enabled: boolean): void { void writeSettingsFile(currentSettings); } +export function getDeleteCompactProgressOnFinish(): boolean { + return currentSettings.deleteCompactProgressOnFinish ?? false; +} + +export function setDeleteCompactProgressOnFinish(enabled: boolean): void { + currentSettings.deleteCompactProgressOnFinish = enabled; + void writeSettingsFile(currentSettings); +} + export function getShowThinkingContent(): boolean { return currentSettings.showThinkingContent ?? true; } @@ -328,6 +337,7 @@ function applyInitialSettingsPreset(preset: Record): void { const knownKeys = new Set([ "ttsMode", "compactOutputMode", + "deleteCompactProgressOnFinish", "showThinkingContent", "showAssistantRunFooter", "responseStreamingMode", @@ -363,7 +373,7 @@ function applyInitialSettingsPreset(preset: Record): void { currentSettings.responseStreamingMode = value as ResponseStreamingMode; } } else { - // Boolean settings: compactOutputMode, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments, promptQueueEnabled + // Boolean settings: compactOutputMode, deleteCompactProgressOnFinish, showThinkingContent, showAssistantRunFooter, sendDiffFileAttachments, promptQueueEnabled if (typeof value !== "boolean") { throw new Error( `INITIAL_SETTINGS_PRESET: "${key}" must be a boolean.`, @@ -374,6 +384,10 @@ function applyInitialSettingsPreset(preset: Record): void { if (currentSettings.compactOutputMode === undefined) currentSettings.compactOutputMode = value; break; + case "deleteCompactProgressOnFinish": + if (currentSettings.deleteCompactProgressOnFinish === undefined) + currentSettings.deleteCompactProgressOnFinish = value; + break; case "showThinkingContent": if (currentSettings.showThinkingContent === undefined) currentSettings.showThinkingContent = value; diff --git a/src/app/types/settings.ts b/src/app/types/settings.ts index 027c0afc0..893623ce2 100644 --- a/src/app/types/settings.ts +++ b/src/app/types/settings.ts @@ -18,6 +18,7 @@ export interface Settings { pinnedMessageId?: number | undefined; ttsMode?: "off" | "all" | "auto" | undefined; compactOutputMode?: boolean | undefined; + deleteCompactProgressOnFinish?: boolean | undefined; showThinkingContent?: boolean | undefined; showAssistantRunFooter?: boolean | undefined; responseStreamingMode?: ResponseStreamingMode | undefined; diff --git a/src/bot/callbacks/settings-callback-handler.ts b/src/bot/callbacks/settings-callback-handler.ts index 42d95ae91..1e2999af6 100644 --- a/src/bot/callbacks/settings-callback-handler.ts +++ b/src/bot/callbacks/settings-callback-handler.ts @@ -2,6 +2,7 @@ import type { Context } from "grammy"; import { isTtsConfigured } from "../../app/services/tts-service.js"; import { getCompactOutputMode, + getDeleteCompactProgressOnFinish, getPromptQueueEnabled, getResponseStreamingMode, getSendDiffFileAttachments, @@ -9,6 +10,7 @@ import { getShowThinkingContent, getTtsMode, setCompactOutputMode, + setDeleteCompactProgressOnFinish, setPromptQueueEnabled, setResponseStreamingMode, setSendDiffFileAttachments, @@ -26,6 +28,7 @@ import { SETTINGS_ASSISTANT_FOOTER_CALLBACK, SETTINGS_CALLBACK_PREFIX, SETTINGS_COMPACT_OUTPUT_CALLBACK, + SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK, SETTINGS_DIFF_FILES_CALLBACK, SETTINGS_PROMPT_QUEUE_CALLBACK, SETTINGS_RESPONSE_STREAMING_CALLBACK, @@ -84,6 +87,16 @@ export async function handleSettingsCallback(ctx: Context): Promise { return true; } + if (callbackData === SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK) { + setDeleteCompactProgressOnFinish(!getDeleteCompactProgressOnFinish()); + const { text, keyboard } = buildSettingsMenuView(); + await ctx.answerCallbackQuery({ text: t("settings.saved") }); + await ctx.editMessageText(text, { + reply_markup: appendInlineMenuCancelButton(keyboard, "settings"), + }); + return true; + } + if (callbackData === SETTINGS_THINKING_CONTENT_CALLBACK) { setShowThinkingContent(!getShowThinkingContent()); const { text, keyboard } = buildSettingsMenuView(); diff --git a/src/bot/menus/settings-menu.ts b/src/bot/menus/settings-menu.ts index beff46b80..cc8608203 100644 --- a/src/bot/menus/settings-menu.ts +++ b/src/bot/menus/settings-menu.ts @@ -1,6 +1,7 @@ import { InlineKeyboard } from "grammy"; import { getCompactOutputMode, + getDeleteCompactProgressOnFinish, getPromptQueueEnabled, getResponseStreamingMode, getSendDiffFileAttachments, @@ -14,6 +15,7 @@ import { t } from "../../i18n/index.js"; export const SETTINGS_CALLBACK_PREFIX = "settings:"; export const SETTINGS_COMPACT_OUTPUT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}compact_output`; +export const SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}delete_progress_on_finish`; export const SETTINGS_THINKING_CONTENT_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}thinking_content`; export const SETTINGS_RESPONSE_STREAMING_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}response_streaming`; export const SETTINGS_DIFF_FILES_CALLBACK = `${SETTINGS_CALLBACK_PREFIX}diff_files`; @@ -45,6 +47,7 @@ export function formatResponseStreamingModeValue(mode: ResponseStreamingMode): s export function buildSettingsMenuView(): { text: string; keyboard: InlineKeyboard } { const compactOutputMode = getCompactOutputMode(); + const deleteCompactProgressOnFinish = getDeleteCompactProgressOnFinish(); const showThinkingContent = getShowThinkingContent(); const responseStreamingMode = getResponseStreamingMode(); const sendDiffFileAttachments = getSendDiffFileAttachments(); @@ -57,7 +60,12 @@ export function buildSettingsMenuView(): { text: string; keyboard: InlineKeyboar SETTINGS_COMPACT_OUTPUT_CALLBACK, ); - if (!compactOutputMode) { + if (compactOutputMode) { + keyboard.row().text( + `${t("settings.delete_progress_on_finish.label")}: ${formatBooleanSettingValue(deleteCompactProgressOnFinish)}`, + SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK, + ); + } else { keyboard.row().text( `${t("settings.thinking_content.label")}: ${formatBooleanSettingValue(showThinkingContent)}`, SETTINGS_THINKING_CONTENT_CALLBACK, diff --git a/src/bot/services/event-subscription-service.ts b/src/bot/services/event-subscription-service.ts index 5f6d4478e..e2fe62caa 100644 --- a/src/bot/services/event-subscription-service.ts +++ b/src/bot/services/event-subscription-service.ts @@ -21,6 +21,7 @@ import { import { ToolMessageBatcher } from "../../app/formatters/tool-message-batcher.js"; import { getCompactOutputMode, + getDeleteCompactProgressOnFinish, getResponseStreamingMode, getSendDiffFileAttachments, getShowAssistantRunFooter, @@ -262,6 +263,29 @@ class EventSubscriptionService implements BotEventSubscriptionService { throw error; } }, + deleteText: async (sessionId, messageId) => { + if (!this.botInstance || !this.chatIdInstance || this.chatIdInstance <= 0) { + throw new Error("Bot context missing for compact progress delete"); + } + + const currentSession = getCurrentSession(); + if (!currentSession || currentSession.id !== sessionId) { + throw new Error(`Compact progress session mismatch for delete: ${sessionId}`); + } + + await this.botInstance.api.deleteMessage(this.chatIdInstance, messageId).catch((error) => { + const errorMessage = + error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + if ( + errorMessage.includes("message to delete not found") || + errorMessage.includes("message identifier is not specified") + ) { + return; + } + + throw error; + }); + }, }); this.toolCallStreamer = new ToolCallStreamer({ @@ -1637,7 +1661,9 @@ class EventSubscriptionService implements BotEventSubscriptionService { return existingTask; } - const nextTask = this.compactProgressStreamer.finalize(sessionId).finally(() => { + const nextTask = this.compactProgressStreamer + .finalize(sessionId, getDeleteCompactProgressOnFinish()) + .finally(() => { if (this.compactProgressFinalizationTasks.get(sessionId) === nextTask) { this.compactProgressFinalizationTasks.delete(sessionId); } diff --git a/src/bot/streaming/compact-progress-streamer.ts b/src/bot/streaming/compact-progress-streamer.ts index 87db67676..aaa52cefc 100644 --- a/src/bot/streaming/compact-progress-streamer.ts +++ b/src/bot/streaming/compact-progress-streamer.ts @@ -20,6 +20,7 @@ export interface CompactProgressStreamerOptions { throttleMs: StreamThrottleMs; sendText: (sessionId: string, text: string) => Promise; editText: (sessionId: string, messageId: number, text: string) => Promise; + deleteText?: (sessionId: string, messageId: number) => Promise; } function getErrorMessage(error: unknown): string { @@ -44,11 +45,13 @@ export class CompactProgressStreamer { private readonly throttleMs: StreamThrottleMs; private readonly sendText: CompactProgressStreamerOptions["sendText"]; private readonly editText: CompactProgressStreamerOptions["editText"]; + private readonly deleteText: CompactProgressStreamerOptions["deleteText"]; - constructor({ throttleMs, sendText, editText }: CompactProgressStreamerOptions) { + constructor({ throttleMs, sendText, editText, deleteText }: CompactProgressStreamerOptions) { this.throttleMs = throttleMs; this.sendText = sendText; this.editText = editText; + this.deleteText = deleteText; } private resolveThrottleMs(sessionId: string): number { @@ -92,25 +95,48 @@ export class CompactProgressStreamer { this.states.get(sessionId)?.filePaths.add(normalizedPath); } - async finalize(sessionId: string): Promise { + async finalize(sessionId: string, deleteOnFinish = false): Promise { const state = this.states.get(sessionId); if (!state) { return; } + this.clearTimer(state); + await state.task.catch(() => false); + + if (deleteOnFinish && this.deleteText) { + await this.deleteProgressMessage(state); + this.cancelState(state); + this.states.delete(sessionId); + return; + } + state.latestText = t("progress.compact.done", { header: t("progress.compact.finished_header"), tools: state.toolCallIds.size, files: state.filePaths.size, }); - this.clearTimer(state); - await state.task.catch(() => false); await this.syncState(state, "finalize"); this.cancelState(state); this.states.delete(sessionId); } + private async deleteProgressMessage(state: CompactProgressState): Promise { + if (state.messageId === null) { + return; + } + + try { + await this.deleteText?.(state.sessionId, state.messageId); + } catch (error) { + logger.error( + `[CompactProgress] Failed to delete progress message: session=${state.sessionId}, error=${getErrorMessage(error)}`, + error, + ); + } + } + clearSession(sessionId: string, reason: string): void { const state = this.states.get(sessionId); if (!state) { diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 5b6b85281..81b120a54 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -152,6 +152,7 @@ export const ar: I18nDictionary = { "settings.menu.title": "⚙️ إعدادات البوت\nاضغط على إعداد لتبديل قيمته:", "settings.compact_output.label": "وضع الإخراج المختصر", + "settings.delete_progress_on_finish.label": "حذف التقدم عند الانتهاء", "settings.thinking_content.label": "محتوى التفكير", "settings.response_streaming.label": "بث الرد", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 5004af987..67bb99efc 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -159,6 +159,7 @@ export const de: I18nDictionary = { "settings.menu.title": "⚙️ Bot-Einstellungen\nTippen Sie auf eine Einstellung, um ihren Wert umzuschalten:", "settings.compact_output.label": "Kompakte Ausgabe", + "settings.delete_progress_on_finish.label": "Fortschritt beim Abschluss löschen", "settings.thinking_content.label": "Thinking-Inhalt", "settings.response_streaming.label": "Antwort-Streaming", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 98b015930..742de11d6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -150,6 +150,7 @@ export const en = { "settings.menu.title": "⚙️ Bot settings\nTap a setting to toggle its value:", "settings.compact_output.label": "Compact output mode", + "settings.delete_progress_on_finish.label": "Delete progress on finish", "settings.thinking_content.label": "Thinking content", "settings.response_streaming.label": "Response streaming", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 7277c6aca..96a75ad23 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -159,6 +159,7 @@ export const es: I18nDictionary = { "settings.menu.title": "⚙️ Ajustes del bot\nToca un ajuste para cambiar su valor:", "settings.compact_output.label": "Salida compacta", + "settings.delete_progress_on_finish.label": "Borrar progreso al terminar", "settings.thinking_content.label": "Contenido de thinking", "settings.response_streaming.label": "Streaming de respuesta", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index dc9008f37..c9d18083b 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -161,6 +161,7 @@ export const fr: I18nDictionary = { "settings.menu.title": "⚙️ Paramètres du bot\nTouchez un paramètre pour basculer sa valeur :", "settings.compact_output.label": "Sortie compacte", + "settings.delete_progress_on_finish.label": "Supprimer la progression à la fin", "settings.thinking_content.label": "Contenu thinking", "settings.response_streaming.label": "Streaming de réponse", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 78bff01ba..92856195b 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -163,6 +163,7 @@ export const it: I18nDictionary = { "settings.menu.title": "⚙️ Impostazioni del bot\nPremi su un'impostazione per cambiarne il valore:", "settings.compact_output.label": "Modalità output compatta", + "settings.delete_progress_on_finish.label": "Elimina progresso al termine", "settings.thinking_content.label": "Contenuto del pensiero", "settings.response_streaming.label": "Streaming della risposta", "settings.response_streaming.edit": "modifica", diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 0f78a6048..3302576e8 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -159,6 +159,7 @@ export const ko: I18nDictionary = { "settings.menu.title": "⚙️ 봇 설정\n항목을 탭하여 값을 전환하세요:", "settings.compact_output.label": "간결 출력 모드", + "settings.delete_progress_on_finish.label": "완료 시 진행 삭제", "settings.thinking_content.label": "생각 내용", "settings.response_streaming.label": "응답 스트리밍", "settings.response_streaming.edit": "편집", diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 01bdb0047..55171663e 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -159,6 +159,7 @@ export const pt: I18nDictionary = { "settings.menu.title": "⚙️ Configurações do bot\nToque em uma configuração para alternar seu valor:", "settings.compact_output.label": "Modo de saída compacta", + "settings.delete_progress_on_finish.label": "Apagar progresso ao terminar", "settings.thinking_content.label": "Conteúdo do thinking", "settings.response_streaming.label": "Streaming de resposta", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 96e217192..15549c044 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -150,6 +150,7 @@ export const ru: I18nDictionary = { "settings.menu.title": "⚙️ Настройки бота\nНажмите на параметр, чтобы переключить его значение:", "settings.compact_output.label": "Компактный вывод", + "settings.delete_progress_on_finish.label": "Удалять прогресс по завершении", "settings.thinking_content.label": "Содержимое thinking", "settings.response_streaming.label": "Стриминг ответа", "settings.response_streaming.edit": "edit", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index eb949dec4..2bbd8fb8e 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -139,6 +139,7 @@ export const zh: I18nDictionary = { "settings.menu.title": "⚙️ 机器人设置\n点按设置项以切换其值:", "settings.compact_output.label": "紧凑输出模式", + "settings.delete_progress_on_finish.label": "完成后删除进度", "settings.thinking_content.label": "思考内容", "settings.response_streaming.label": "回复流式模式", "settings.response_streaming.edit": "edit", diff --git a/tests/app/stores/settings-store.test.ts b/tests/app/stores/settings-store.test.ts index 7cbb72fd6..e7f149ee7 100644 --- a/tests/app/stores/settings-store.test.ts +++ b/tests/app/stores/settings-store.test.ts @@ -106,7 +106,7 @@ describe("app/stores/settings-store", () => { vi.resetModules(); vi.stubEnv( "INITIAL_SETTINGS_PRESET", - '{"showAssistantRunFooter":false,"compactOutputMode":true,"ttsMode":"auto","responseStreamingMode":"draft","sendDiffFileAttachments":false,"showThinkingContent":false,"promptQueueEnabled":true}', + '{"showAssistantRunFooter":false,"compactOutputMode":true,"deleteCompactProgressOnFinish":true,"ttsMode":"auto","responseStreamingMode":"draft","sendDiffFileAttachments":false,"showThinkingContent":false,"promptQueueEnabled":true}', ); const store = await import("../../../src/app/stores/settings-store.js"); @@ -114,6 +114,7 @@ describe("app/stores/settings-store", () => { expect(store.getShowAssistantRunFooter()).toBe(false); expect(store.getCompactOutputMode()).toBe(true); + expect(store.getDeleteCompactProgressOnFinish()).toBe(true); expect(store.getTtsMode()).toBe("auto"); expect(store.getResponseStreamingMode()).toBe("draft"); expect(store.getSendDiffFileAttachments()).toBe(false); diff --git a/tests/bot/commands/settings.test.ts b/tests/bot/commands/settings.test.ts index ba843caad..622e3482d 100644 --- a/tests/bot/commands/settings.test.ts +++ b/tests/bot/commands/settings.test.ts @@ -9,6 +9,7 @@ import { SETTINGS_ASSISTANT_FOOTER_CALLBACK, SETTINGS_CALLBACK_PREFIX, SETTINGS_COMPACT_OUTPUT_CALLBACK, + SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK, SETTINGS_DIFF_FILES_CALLBACK, SETTINGS_PROMPT_QUEUE_CALLBACK, SETTINGS_RESPONSE_STREAMING_CALLBACK, @@ -19,6 +20,8 @@ import { const mocked = vi.hoisted(() => ({ getCompactOutputModeMock: vi.fn(), setCompactOutputModeMock: vi.fn(), + getDeleteCompactProgressOnFinishMock: vi.fn(), + setDeleteCompactProgressOnFinishMock: vi.fn(), getResponseStreamingModeMock: vi.fn(), setResponseStreamingModeMock: vi.fn(), getSendDiffFileAttachmentsMock: vi.fn(), @@ -37,6 +40,8 @@ const mocked = vi.hoisted(() => ({ vi.mock("../../../src/app/stores/settings-store.js", () => ({ getCompactOutputMode: mocked.getCompactOutputModeMock, setCompactOutputMode: mocked.setCompactOutputModeMock, + getDeleteCompactProgressOnFinish: mocked.getDeleteCompactProgressOnFinishMock, + setDeleteCompactProgressOnFinish: mocked.setDeleteCompactProgressOnFinishMock, getResponseStreamingMode: mocked.getResponseStreamingModeMock, setResponseStreamingMode: mocked.setResponseStreamingModeMock, getSendDiffFileAttachments: mocked.getSendDiffFileAttachmentsMock, @@ -59,6 +64,8 @@ describe("bot/commands/settings-command", () => { beforeEach(() => { mocked.getCompactOutputModeMock.mockReset(); mocked.setCompactOutputModeMock.mockReset(); + mocked.getDeleteCompactProgressOnFinishMock.mockReset(); + mocked.setDeleteCompactProgressOnFinishMock.mockReset(); mocked.getResponseStreamingModeMock.mockReset(); mocked.setResponseStreamingModeMock.mockReset(); mocked.getSendDiffFileAttachmentsMock.mockReset(); @@ -81,6 +88,7 @@ describe("bot/commands/settings-command", () => { it("shows settings menu with current compact output and TTS modes", async () => { mocked.getCompactOutputModeMock.mockReturnValue(true); + mocked.getDeleteCompactProgressOnFinishMock.mockReturnValue(true); mocked.getShowThinkingContentMock.mockReturnValue(true); mocked.getTtsModeMock.mockReturnValue("auto"); const replyMock = vi.fn().mockResolvedValue({ message_id: 10 }); @@ -100,22 +108,26 @@ describe("bot/commands/settings-command", () => { `${t("settings.compact_output.label")}: ${t("settings.value.on")}`, ); expect(opts.reply_markup.inline_keyboard[1][0].text).toBe( - `${t("settings.response_streaming.label")}: ${t("settings.response_streaming.edit")}`, + `${t("settings.delete_progress_on_finish.label")}: ${t("settings.value.on")}`, ); expect(opts.reply_markup.inline_keyboard[2][0].text).toBe( - `${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`, + `${t("settings.response_streaming.label")}: ${t("settings.response_streaming.edit")}`, ); expect(opts.reply_markup.inline_keyboard[3][0].text).toBe( - `${t("settings.tts.label")}: ${t("status.tts.auto")}`, + `${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`, ); expect(opts.reply_markup.inline_keyboard[4][0].text).toBe( + `${t("settings.tts.label")}: ${t("status.tts.auto")}`, + ); + expect(opts.reply_markup.inline_keyboard[5][0].text).toBe( `${t("settings.prompt_queue.label")}: ${t("settings.value.off")}`, ); - expect(opts.reply_markup.inline_keyboard[5][0].text).toBe(t("inline.button.close")); + expect(opts.reply_markup.inline_keyboard[6][0].text).toBe(t("inline.button.close")); }); it("shows thinking content setting when compact output is disabled", async () => { mocked.getCompactOutputModeMock.mockReturnValue(false); + mocked.getDeleteCompactProgressOnFinishMock.mockReturnValue(false); mocked.getShowThinkingContentMock.mockReturnValue(true); mocked.getTtsModeMock.mockReturnValue("off"); const replyMock = vi.fn().mockResolvedValue({ message_id: 10 }); @@ -151,6 +163,7 @@ describe("bot/commands/settings-command", () => { it("marks draft response streaming mode as experimental", async () => { mocked.getCompactOutputModeMock.mockReturnValue(false); + mocked.getDeleteCompactProgressOnFinishMock.mockReturnValue(false); mocked.getShowThinkingContentMock.mockReturnValue(true); mocked.getResponseStreamingModeMock.mockReturnValue("draft"); mocked.getTtsModeMock.mockReturnValue("off"); @@ -175,6 +188,8 @@ describe("bot/callbacks/settings-callback-handler", () => { beforeEach(() => { mocked.getCompactOutputModeMock.mockReset(); mocked.setCompactOutputModeMock.mockReset(); + mocked.getDeleteCompactProgressOnFinishMock.mockReset(); + mocked.setDeleteCompactProgressOnFinishMock.mockReset(); mocked.getResponseStreamingModeMock.mockReset(); mocked.setResponseStreamingModeMock.mockReset(); mocked.getSendDiffFileAttachmentsMock.mockReset(); @@ -216,6 +231,7 @@ describe("bot/callbacks/settings-callback-handler", () => { it("toggles compact output mode and returns to settings menu", async () => { mocked.getCompactOutputModeMock.mockReturnValueOnce(false).mockReturnValueOnce(true); + mocked.getDeleteCompactProgressOnFinishMock.mockReturnValue(false); mocked.getShowThinkingContentMock.mockReturnValue(true); mocked.getTtsModeMock.mockReturnValue("off"); activateSettingsMenu(); @@ -233,16 +249,41 @@ describe("bot/callbacks/settings-callback-handler", () => { `${t("settings.compact_output.label")}: ${t("settings.value.on")}`, ); expect(defined(opts?.reply_markup?.inline_keyboard[1]?.[0]).text).toBe( - `${t("settings.response_streaming.label")}: ${t("settings.response_streaming.edit")}`, + `${t("settings.delete_progress_on_finish.label")}: ${t("settings.value.off")}`, ); expect(defined(opts?.reply_markup?.inline_keyboard[2]?.[0]).text).toBe( - `${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`, + `${t("settings.response_streaming.label")}: ${t("settings.response_streaming.edit")}`, ); expect(defined(opts?.reply_markup?.inline_keyboard[3]?.[0]).text).toBe( + `${t("settings.assistant_footer.label")}: ${t("settings.value.on")}`, + ); + expect(defined(opts?.reply_markup?.inline_keyboard[4]?.[0]).text).toBe( `${t("settings.tts.label")}: ${t("status.tts.off")}`, ); }); + it("toggles delete progress on finish and returns to settings menu", async () => { + mocked.getCompactOutputModeMock.mockReturnValue(true); + mocked.getDeleteCompactProgressOnFinishMock + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + mocked.getTtsModeMock.mockReturnValue("off"); + activateSettingsMenu(); + const ctx = createCallbackContext(SETTINGS_DELETE_PROGRESS_ON_FINISH_CALLBACK); + + const result = await handleSettingsCallback(ctx); + + expect(result).toBe(true); + expect(mocked.setDeleteCompactProgressOnFinishMock).toHaveBeenCalledWith(true); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: t("settings.saved") }); + const call = defined(vi.mocked(ctx.editMessageText).mock.calls[0]); + const [text, opts] = call; + expect(text).toBe(t("settings.menu.title")); + expect(defined(opts?.reply_markup?.inline_keyboard[1]?.[0]).text).toBe( + `${t("settings.delete_progress_on_finish.label")}: ${t("settings.value.on")}`, + ); + }); + it("toggles thinking content and returns to settings menu", async () => { mocked.getCompactOutputModeMock.mockReturnValue(false); mocked.getShowThinkingContentMock.mockReturnValueOnce(true).mockReturnValueOnce(false); diff --git a/tests/bot/streaming/compact-progress-streamer.test.ts b/tests/bot/streaming/compact-progress-streamer.test.ts index a4fdf7e31..6d9dfa920 100644 --- a/tests/bot/streaming/compact-progress-streamer.test.ts +++ b/tests/bot/streaming/compact-progress-streamer.test.ts @@ -25,6 +25,52 @@ describe("bot/streaming/compact-progress-streamer", () => { ); }); + it("deletes the progress message on finalize when deleteOnFinish is set", async () => { + const sendText = vi.fn().mockResolvedValue(10); + const editText = vi.fn().mockResolvedValue(undefined); + const deleteText = vi.fn().mockResolvedValue(undefined); + const streamer = new CompactProgressStreamer({ + throttleMs: 0, + sendText, + editText, + deleteText, + }); + + streamer.updateActivity("s1", "working"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await streamer.finalize("s1", true); + + expect(sendText).toHaveBeenCalledTimes(1); + expect(sendText).toHaveBeenCalledWith("s1", "⏳ Working\nworking"); + expect(deleteText).toHaveBeenCalledTimes(1); + expect(deleteText).toHaveBeenCalledWith("s1", 10); + expect(editText).not.toHaveBeenCalled(); + }); + + it("keeps the final summary edit when deleteOnFinish is false", async () => { + const sendText = vi.fn().mockResolvedValue(10); + const editText = vi.fn().mockResolvedValue(undefined); + const deleteText = vi.fn().mockResolvedValue(undefined); + const streamer = new CompactProgressStreamer({ + throttleMs: 0, + sendText, + editText, + deleteText, + }); + + streamer.updateActivity("s1", "working"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await streamer.finalize("s1", false); + + expect(deleteText).not.toHaveBeenCalled(); + expect(editText).toHaveBeenCalledTimes(1); + expect(editText).toHaveBeenCalledWith( + "s1", + 10, + "✅ Finished Work\ntool calls: 0 · changed files: 0", + ); + }); + it("does not create a message for thinking-only activity", async () => { const sendText = vi.fn().mockResolvedValue(10); const editText = vi.fn().mockResolvedValue(undefined);