diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 4b540ed0..de67a7a0 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -154,6 +154,29 @@ function formatDateTime(dateIso: string | null, timezone: string): string { } } +const TASK_DETAIL_PROMPT_BYTE_BUDGET = 3800; + +function truncatePromptForDetails(prompt: string): string { + if (Buffer.byteLength(prompt, "utf-8") <= TASK_DETAIL_PROMPT_BYTE_BUDGET) { + return prompt; + } + + const budget = TASK_DETAIL_PROMPT_BYTE_BUDGET - 3; + let lo = 0; + let hi = prompt.length; + + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (Buffer.byteLength(prompt.slice(0, mid), "utf-8") <= budget) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return `${prompt.slice(0, lo)}...`; +} + function formatTaskDetails(task: ScheduledTask): string { const variant = task.model.variant ? ` (${task.model.variant})` : ""; const model = `${task.model.providerID}/${task.model.modelID}${variant}`; @@ -161,7 +184,7 @@ function formatTaskDetails(task: ScheduledTask): string { task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : ""; return t("tasklist.details", { - prompt: task.prompt, + prompt: truncatePromptForDetails(task.prompt), project: `${task.projectWorktree}\n${t("status.line.model", { model })}`, schedule: task.scheduleSummary, cronLine, diff --git a/tests/bot/commands/tasklist.test.ts b/tests/bot/commands/tasklist.test.ts index f43a5ba9..feb404dc 100644 --- a/tests/bot/commands/tasklist.test.ts +++ b/tests/bot/commands/tasklist.test.ts @@ -257,4 +257,83 @@ describe("bot/commands/tasklist", () => { show_alert: true, }); }); + + it("truncates long ASCII prompt by byte length to fit Telegram limit", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 700, + }, + }); + + const longPrompt = "A".repeat(4000); + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-long", { + prompt: longPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-long", 700); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain("..."); + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096); + }); + + it("keeps short prompts intact without truncation", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 800, + }, + }); + + const shortPrompt = "Check weather"; + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-short", { + prompt: shortPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-short", 800); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain(shortPrompt); + expect(text).not.toContain("..."); + }); + + it("truncates non-ASCII prompts by byte length (Arabic/Cyrillic)", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 900, + }, + }); + + // Arabic chars are ~2 bytes each in UTF-8 — 2500 chars = ~5000 bytes + const arabicPrompt = "مرحباً".repeat(500); + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-ar", { + prompt: arabicPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-ar", 900); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain("..."); + expect(Buffer.byteLength(text, "utf-8")).toBeLessThanOrEqual(4096); + }); });