From 90c3393d4bd09ea6919c00530985c35d3da6ad09 Mon Sep 17 00:00:00 2001 From: Alexander Borovsky Date: Tue, 9 Jun 2026 23:58:36 +0200 Subject: [PATCH 1/6] fix(tasklist): truncate long prompts to prevent editMessageText exceeding 4096 bytes --- src/bot/callbacks/scheduled-task-callback-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 4b540ed0..367c99fd 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -161,7 +161,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: truncateText(task.prompt, 3800), project: `${task.projectWorktree}\n${t("status.line.model", { model })}`, schedule: task.scheduleSummary, cronLine, From f5760fd5545f23f38974919adbd3a9cf8ad12690 Mon Sep 17 00:00:00 2001 From: Alexander Borovsky Date: Tue, 9 Jun 2026 23:59:01 +0200 Subject: [PATCH 2/6] test(tasklist): add test for oversized prompt truncation --- tests/bot/commands/tasklist.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/bot/commands/tasklist.test.ts b/tests/bot/commands/tasklist.test.ts index f43a5ba9..7cda8b33 100644 --- a/tests/bot/commands/tasklist.test.ts +++ b/tests/bot/commands/tasklist.test.ts @@ -189,6 +189,34 @@ describe("bot/commands/tasklist", () => { }); }); + it("truncates oversized prompt to fit Telegram 4096-byte message limit", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 300, + }, + }); + + const longPrompt = "A".repeat(5000); + + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-1", { + prompt: longPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-1", 300); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).not.toContain(longPrompt); + expect(text).toContain("AAA..."); + expect(text.length).toBeLessThanOrEqual(4096); + }); + it("cancels task details interaction and removes message", async () => { interactionManager.start({ kind: "custom", From 3d779a7521f2493cb4e737120279837809cf0a52 Mon Sep 17 00:00:00 2001 From: Alexander Borovsky Date: Wed, 10 Jun 2026 00:04:17 +0200 Subject: [PATCH 3/6] fix(tasklist): use byte-aware truncation for Telegram 4096-byte editMessageText limit --- .../scheduled-task-callback-handler.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 367c99fd..85573202 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -138,6 +138,32 @@ function clearTaskListInteraction(reason: string): void { } } +/** + * Truncates text so its UTF-8 byte length does not exceed maxBytes. + * Appends "..." when truncation occurs. + * Uses TextEncoder to count bytes accurately (handles emoji, CJK, etc.). + */ +function truncateToByteLength(text: string, maxBytes: number): string { + const encoder = new TextEncoder(); + if (encoder.encode(text).length <= maxBytes) { + return text; + } + + // Binary-search for the longest prefix that fits within (maxBytes - 3) bytes + let low = 0; + let high = text.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (encoder.encode(text.slice(0, mid)).length <= maxBytes - 3) { + low = mid; + } else { + high = mid - 1; + } + } + + return `${text.slice(0, low).trimEnd()}...`; +} + function formatDateTime(dateIso: string | null, timezone: string): string { if (!dateIso) { return "-"; @@ -161,7 +187,10 @@ function formatTaskDetails(task: ScheduledTask): string { task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : ""; return t("tasklist.details", { - prompt: truncateText(task.prompt, 3800), + // Telegram editMessageText hard limit is 4096 bytes (UTF-8). + // Template chrome (title, labels, schedule, etc.) consumes ~230 bytes. + // A 3800-byte budget for the prompt keeps the total safely under the limit. + prompt: truncateToByteLength(task.prompt, 3800), project: `${task.projectWorktree}\n${t("status.line.model", { model })}`, schedule: task.scheduleSummary, cronLine, From e213fe33ae0346f9684a1bf266c9dfefa1f3132f Mon Sep 17 00:00:00 2001 From: Alexander Borovsky Date: Wed, 10 Jun 2026 00:04:40 +0200 Subject: [PATCH 4/6] test(tasklist): use byte-length assertions and add multi-byte truncation test --- tests/bot/commands/tasklist.test.ts | 65 +++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/tests/bot/commands/tasklist.test.ts b/tests/bot/commands/tasklist.test.ts index 7cda8b33..269828ac 100644 --- a/tests/bot/commands/tasklist.test.ts +++ b/tests/bot/commands/tasklist.test.ts @@ -189,7 +189,7 @@ describe("bot/commands/tasklist", () => { }); }); - it("truncates oversized prompt to fit Telegram 4096-byte message limit", async () => { + it("truncates oversized ASCII prompt to fit Telegram 4096-byte message limit", async () => { interactionManager.start({ kind: "custom", expectedInput: "callback", @@ -213,8 +213,67 @@ describe("bot/commands/tasklist", () => { const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; expect(text).not.toContain(longPrompt); - expect(text).toContain("AAA..."); - expect(text.length).toBeLessThanOrEqual(4096); + expect(text).toContain("AAA"); + expect(text).toMatch(/\.\.\.(\s|$)/m); + // Byte length must not exceed Telegram limit + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(4096); + }); + + it("truncates oversized multi-byte prompt to fit Telegram 4096-byte message limit", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 301, + }, + }); + + // Each 🚀 emoji is 4 UTF-8 bytes. 1200 emojis = 4800 bytes (well over 3800). + const longPrompt = "🚀".repeat(1200); + + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-1", { + prompt: longPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-1", 301); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).not.toContain(longPrompt); + expect(text).toMatch(/\.\.\.(\s|$)/m); + // Byte length must not exceed Telegram limit + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(4096); + }); + + it("does not truncate prompt that fits within the byte limit", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "callback", + metadata: { + flow: "tasklist", + stage: "list", + messageId: 302, + }, + }); + + const shortPrompt = "Check the weather forecast"; + + mocked.getScheduledTaskMock.mockReturnValue( + createTask("task-1", { + prompt: shortPrompt, + }), + ); + + const ctx = createCallbackContext("tasklist:open:task-1", 302); + await handleTaskListCallback(ctx); + + const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; + expect(text).toContain(shortPrompt); + expect(text).not.toMatch(/Check the weather forecast\.\.\./); }); it("cancels task details interaction and removes message", async () => { From c4d2cf41d4e4dd00a72361c8f4d2f33ba75f3679 Mon Sep 17 00:00:00 2001 From: Alexander Borovsky Date: Wed, 10 Jun 2026 00:12:36 +0200 Subject: [PATCH 5/6] fix(tasklist): guard against surrogate pair split in truncateToByteLength --- src/bot/callbacks/scheduled-task-callback-handler.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index 85573202..e21a9201 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -149,7 +149,8 @@ function truncateToByteLength(text: string, maxBytes: number): string { return text; } - // Binary-search for the longest prefix that fits within (maxBytes - 3) bytes + // Binary-search for the longest prefix that fits within (maxBytes - 3) bytes. + // We search over UTF-16 code unit indices (JS string positions). let low = 0; let high = text.length; while (low < high) { @@ -161,6 +162,13 @@ function truncateToByteLength(text: string, maxBytes: number): string { } } + // Snap back if `low` cuts in the middle of a surrogate pair. + // If the last code unit in the slice is a high surrogate (0xD800–0xDBFF), + // the slice ends with an unpaired surrogate — step back one code unit. + if (low > 0 && text.charCodeAt(low - 1) >= 0xd800 && text.charCodeAt(low - 1) <= 0xdbff) { + low -= 1; + } + return `${text.slice(0, low).trimEnd()}...`; } From a7ab92ae7b93aa81f0cc78d3892a1b820b23226d Mon Sep 17 00:00:00 2001 From: Jordan Belfort Date: Thu, 18 Jun 2026 03:46:12 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20clarify=203800-byte=20budget,=20add=20emoji=20survi?= =?UTF-8?q?val=20+=20byte-length=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot/callbacks/scheduled-task-callback-handler.ts | 8 ++++++-- tests/bot/commands/tasklist.test.ts | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/bot/callbacks/scheduled-task-callback-handler.ts b/src/bot/callbacks/scheduled-task-callback-handler.ts index e21a9201..c76fc6be 100644 --- a/src/bot/callbacks/scheduled-task-callback-handler.ts +++ b/src/bot/callbacks/scheduled-task-callback-handler.ts @@ -196,8 +196,12 @@ function formatTaskDetails(task: ScheduledTask): string { return t("tasklist.details", { // Telegram editMessageText hard limit is 4096 bytes (UTF-8). - // Template chrome (title, labels, schedule, etc.) consumes ~230 bytes. - // A 3800-byte budget for the prompt keeps the total safely under the limit. + // The prompt budget (3800 bytes) is derived from the English locale: + // 4096 − ~230 (template chrome: title, labels, schedule, etc.) − 66 (safety margin) = 3800. + // NOTE: Non-English locales may have longer template chrome. If a locale's + // chrome exceeds ~296 bytes (230 + 66), the total could exceed 4096. + // This budget is safe for all current locales; re-calculate if new locales + // with significantly longer translations are added. prompt: truncateToByteLength(task.prompt, 3800), project: `${task.projectWorktree}\n${t("status.line.model", { model })}`, schedule: task.scheduleSummary, diff --git a/tests/bot/commands/tasklist.test.ts b/tests/bot/commands/tasklist.test.ts index 269828ac..59e222ba 100644 --- a/tests/bot/commands/tasklist.test.ts +++ b/tests/bot/commands/tasklist.test.ts @@ -245,6 +245,7 @@ describe("bot/commands/tasklist", () => { const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; expect(text).not.toContain(longPrompt); expect(text).toMatch(/\.\.\.(\s|$)/m); + expect(text).toContain("🚀"); // Byte length must not exceed Telegram limit expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(4096); }); @@ -274,6 +275,7 @@ describe("bot/commands/tasklist", () => { const [text] = (ctx.editMessageText as ReturnType).mock.calls[0] as [string]; expect(text).toContain(shortPrompt); expect(text).not.toMatch(/Check the weather forecast\.\.\./); + expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(4096); }); it("cancels task details interaction and removes message", async () => {