From 4929198b5adc08ee30290b15867d2d758b72f49e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 18 Jun 2026 03:24:57 -0700 Subject: [PATCH] FN-6629: Lower task-list text budget Keep fn_task_list responses below host text imageification thresholds.\n\n- Lower the shared task-list text clamp to a 3,000 character host-safe budget.\n- Reuse the shared budget in CLI, dashboard, and engine fallback formatters.\n- Cover realistic column-filtered fn_task_list outputs and fallback budget behavior with regression tests.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .changeset/fn-6629-task-list-budget.md | 5 ++\n packages/cli/src/__tests__/extension.test.ts | 88 ++++++++++++++++++++++\n packages/cli/src/extension.ts | 7 +-\n .../core/src/__tests__/task-list-format.test.ts | 25 ++++++\n packages/core/src/task-list-format.ts | 5 +-\n .../src/__tests__/planning-board-tools.test.ts | 4 +-\n packages/dashboard/src/planning-board-tools.ts | 8 +-\n packages/engine/src/__tests__/triage.test.ts | 4 +-\n packages/engine/src/triage.ts | 7 +-\n 9 files changed, 144 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6629 Fusion-Task-Lineage: 6ec7cd33-d4e5-4c12-8512-c3823a9dfb3e --- .changeset/fn-6629-task-list-budget.md | 5 ++ packages/cli/src/__tests__/extension.test.ts | 88 +++++++++++++++++++ packages/cli/src/extension.ts | 7 +- .../src/__tests__/task-list-format.test.ts | 25 ++++++ packages/core/src/task-list-format.ts | 5 +- .../__tests__/planning-board-tools.test.ts | 4 +- .../dashboard/src/planning-board-tools.ts | 8 +- packages/engine/src/__tests__/triage.test.ts | 4 +- packages/engine/src/triage.ts | 7 +- 9 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 .changeset/fn-6629-task-list-budget.md diff --git a/.changeset/fn-6629-task-list-budget.md b/.changeset/fn-6629-task-list-budget.md new file mode 100644 index 0000000000..32455efc2f --- /dev/null +++ b/.changeset/fn-6629-task-list-budget.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Lower the shared `fn_task_list` plain-text budget and cover filtered column listings with realistic regression cases so large todo, planning, and done outputs stay host-safe. diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 5405e2a929..51f0dc5721 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -2552,11 +2552,18 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); describe("fn_task_list", () => { + const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000; + function expectSingleBoundedTextBlock(result: any) { expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe("text"); expect(result.content[0].text).toBeTruthy(); expect(result.content[0].text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(result.content[0].text.length).toBeLessThanOrEqual(HOST_SAFE_TASK_LIST_TEXT_CEILING); + } + + function realisticTaskTitle(column: string, index: number) { + return `${column} realistic task ${String(index).padStart(3, "0")} keeps enough descriptive context for text agents without artificial padding`; } it("returns bounded text for omitted and provided column/limit params", async () => { @@ -2612,6 +2619,87 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(result.details.count).toBe(2); }); + it("bounds realistic column-filtered listings below the host-safe text budget", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + const todoFirst = await store.createTask({ + title: realisticTaskTitle("todo", 1), + description: "Realistic todo task 001", + column: "todo", + }); + for (let i = 2; i <= 60; i += 1) { + await store.createTask({ + title: realisticTaskTitle("todo", i), + description: `Realistic todo task ${String(i).padStart(3, "0")}`, + column: "todo", + dependencies: [todoFirst.id], + }); + } + for (let i = 1; i <= 35; i += 1) { + await store.createTask({ + title: realisticTaskTitle("triage", i), + description: `Realistic triage task ${String(i).padStart(3, "0")}`, + }); + } + for (let i = 1; i <= 30; i += 1) { + await store.createTask({ + title: realisticTaskTitle("done", i), + description: `Realistic done task ${String(i).padStart(3, "0")}`, + column: "done", + }); + } + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + const broadResult = await listTool.execute( + "list-realistic-broad", + { limit: 20 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + expectSingleBoundedTextBlock(broadResult); + expect(broadResult.content.some((block: any) => block.type === "image")).toBe(false); + expect(broadResult.content[0].text).toContain("Planning (35):"); + expect(broadResult.details.count).toBe(125); + + for (const { callId, params, header, ids } of [ + { + callId: "list-realistic-todo", + params: { column: "todo", limit: 50 }, + header: "Todo (60):", + ids: ["FN-001", "FN-002"], + }, + { + callId: "list-realistic-triage", + params: { column: "triage", limit: 50 }, + header: "Planning (35):", + ids: ["FN-061", "FN-062"], + }, + { + callId: "list-realistic-done", + params: { column: "done", limit: 50 }, + header: "Done (30):", + ids: ["FN-096", "FN-097"], + }, + ] as const) { + const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir)); + const text = result.content[0].text; + + expectSingleBoundedTextBlock(result); + expect(result.content.some((block: any) => block.type === "image")).toBe(false); + expect(text).toContain(header); + for (const id of ids) { + expect(text).toContain(id); + } + expect(text).toContain("truncated to fit; narrow with column/limit"); + expect(result.details.count).toBe(125); + } + }); + it("bounds broad listings as a single plain-text block", async () => { const store = new TaskStore(tmpDir); await store.init(); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index c3fc2d4aba..67e273f983 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -26,6 +26,7 @@ import { getTaskDuplicateLineage, resolveAgentProvisioningPolicy, TASK_PRIORITIES, + MAX_TASK_LIST_TEXT_CHARS, resolveSecretAccessPolicy, getProjectRootFromWorktree, resolveTaskGithubTracking, @@ -69,7 +70,11 @@ export function inlineTaskListFallback( lines: string[], opts: { maxChars?: number } = {}, ): string { - const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 12_000)); + /* + FNXC:TaskListOutput 2026-06-18-03:20: + FN-6629 requires stale-runtime fallback formatting to mirror the shared host-safe task-list budget; otherwise missing @fusion/core formatter exports can re-emit imageified column-filtered listings. + */ + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS)); try { const text = lines.join("\n"); if (text.length <= maxChars) { diff --git a/packages/core/src/__tests__/task-list-format.test.ts b/packages/core/src/__tests__/task-list-format.test.ts index 75b4761edd..b917045f11 100644 --- a/packages/core/src/__tests__/task-list-format.test.ts +++ b/packages/core/src/__tests__/task-list-format.test.ts @@ -201,6 +201,10 @@ describe("formatTaskListText", () => { }); describe("clampTaskListText", () => { + it("documents the host-safe default budget", () => { + expect(MAX_TASK_LIST_TEXT_CHARS).toBe(3_000); + }); + it("returns an empty string for empty input", () => { expect(clampTaskListText([])).toBe(""); }); @@ -264,6 +268,27 @@ describe("clampTaskListText", () => { expect(clampTaskListText(lines).length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); }); + it("truncates realistic large listings under the host-safe budget", () => { + const lines = [ + "Todo (60):", + ...Array.from( + { length: 50 }, + (_, index) => + ` FN-${String(index + 1).padStart(3, "0")} Realistic todo task ${String(index + 1).padStart(3, "0")} keeps descriptive context for text agents without artificial padding`, + ), + " ... and 10 more", + "", + ]; + + const text = clampTaskListText(lines); + + expect(lines.join("\n").length).toBeGreaterThan(MAX_TASK_LIST_TEXT_CHARS); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(text).toContain("Todo (60):"); + expect(text).toContain("FN-001"); + expect(text).toContain("truncated to fit; narrow with column/limit"); + }); + it("handles a single over-budget line by returning a bounded truncation marker", () => { const text = clampTaskListText(["FN-001 " + "x".repeat(200)], { maxChars: 40 }); diff --git a/packages/core/src/task-list-format.ts b/packages/core/src/task-list-format.ts index 8a36a6cf5b..a1feb4f7d1 100644 --- a/packages/core/src/task-list-format.ts +++ b/packages/core/src/task-list-format.ts @@ -1,4 +1,4 @@ -export const MAX_TASK_LIST_TEXT_CHARS = 12_000; +export const MAX_TASK_LIST_TEXT_CHARS = 3_000; const TRUNCATION_HINT = "truncated to fit; narrow with column/limit"; @@ -14,6 +14,9 @@ function joinWithMarker(lines: string[], marker: string): string { * FNXC:TaskListOutput 2026-06-16-17:45: * FN-6492 requires every fn_task_list surface to emit bounded plain text so column-filtered or otherwise large board listings remain readable to text-only heartbeat agents and stay below host runtimes' imageification thresholds. * The default budget is intentionally below common MCP attachment-conversion limits while preserving dozens of compact task rows. + * + * FNXC:TaskListOutput 2026-06-18-03:12: + * FN-6629 lowers the budget from 12,000 because realistic column-filtered heartbeat listings stayed under that old clamp while still exceeding the host imageification threshold. Keep the bound in the low-thousands so todo/triage/done limit-50 outputs remain text-only for heartbeat and other text agents. */ export function clampTaskListText( lines: string[], diff --git a/packages/dashboard/src/__tests__/planning-board-tools.test.ts b/packages/dashboard/src/__tests__/planning-board-tools.test.ts index bbbe8ed484..875bc457e1 100644 --- a/packages/dashboard/src/__tests__/planning-board-tools.test.ts +++ b/packages/dashboard/src/__tests__/planning-board-tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { TaskStore } from "@fusion/core"; +import { MAX_TASK_LIST_TEXT_CHARS, type TaskStore } from "@fusion/core"; import { createPlanningBoardTools, resolveTaskListFormatter } from "../planning-board-tools.js"; function createStoreMock(overrides?: { @@ -33,7 +33,7 @@ describe("fn_task_list resilience (FN-6573)", () => { const formatter = resolveTaskListFormatter(coreNamespace); const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); expect(text).toBeTruthy(); - expect(text.length).toBeLessThanOrEqual(12_000); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); } }); }); diff --git a/packages/dashboard/src/planning-board-tools.ts b/packages/dashboard/src/planning-board-tools.ts index de45ebb649..9c7db2aca1 100644 --- a/packages/dashboard/src/planning-board-tools.ts +++ b/packages/dashboard/src/planning-board-tools.ts @@ -1,5 +1,5 @@ import * as fusionCore from "@fusion/core"; -import type { TaskStore } from "@fusion/core"; +import { MAX_TASK_LIST_TEXT_CHARS, type TaskStore } from "@fusion/core"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string; @@ -12,7 +12,11 @@ export function inlineTaskListFallback( lines: string[], opts: { maxChars?: number } = {}, ): string { - const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 12_000)); + /* + FNXC:TaskListOutput 2026-06-18-03:20: + FN-6629 requires stale-runtime fallback formatting to mirror the shared host-safe task-list budget; otherwise missing @fusion/core formatter exports can re-emit imageified board listings. + */ + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS)); try { const text = lines.join("\n"); if (text.length <= maxChars) { diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 286baea053..50c50c11ab 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core"; -import { builtinSeamPrompt, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core"; +import { builtinSeamPrompt, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core"; import { TriageProcessor, buildSpecificationPrompt, @@ -62,7 +62,7 @@ describe("fn_task_list resilience (FN-6573)", () => { const formatter = resolveTaskListFormatter(coreNamespace); const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); expect(text).toBeTruthy(); - expect(text.length).toBeLessThanOrEqual(12_000); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); } }); }); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 16e757e9d7..b62a246bf0 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -27,6 +27,7 @@ import { findNearDuplicates, isNearDuplicateCanonicalInactive, applyFrontendUxCriteria, + MAX_TASK_LIST_TEXT_CHARS, type NearDuplicateCandidate, } from "@fusion/core"; @@ -40,7 +41,11 @@ export function inlineTaskListFallback( lines: string[], opts: { maxChars?: number } = {}, ): string { - const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 12_000)); + /* + FNXC:TaskListOutput 2026-06-18-03:20: + FN-6629 requires stale-runtime fallback formatting to mirror the shared host-safe task-list budget; otherwise missing @fusion/core formatter exports can re-emit imageified duplicate-check listings. + */ + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS)); try { const text = lines.join("\n"); if (text.length <= maxChars) {