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
This commit is contained in:
gsxdsm
2026-06-18 03:24:57 -07:00
parent 4dd533753e
commit 4929198b5a
9 changed files with 144 additions and 9 deletions

View File

@@ -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.

View File

@@ -2552,11 +2552,18 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
}); });
describe("fn_task_list", () => { describe("fn_task_list", () => {
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;
function expectSingleBoundedTextBlock(result: any) { function expectSingleBoundedTextBlock(result: any) {
expect(result.content).toHaveLength(1); expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text"); expect(result.content[0].type).toBe("text");
expect(result.content[0].text).toBeTruthy(); 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(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 () => { 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); 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 () => { it("bounds broad listings as a single plain-text block", async () => {
const store = new TaskStore(tmpDir); const store = new TaskStore(tmpDir);
await store.init(); await store.init();

View File

@@ -26,6 +26,7 @@ import {
getTaskDuplicateLineage, getTaskDuplicateLineage,
resolveAgentProvisioningPolicy, resolveAgentProvisioningPolicy,
TASK_PRIORITIES, TASK_PRIORITIES,
MAX_TASK_LIST_TEXT_CHARS,
resolveSecretAccessPolicy, resolveSecretAccessPolicy,
getProjectRootFromWorktree, getProjectRootFromWorktree,
resolveTaskGithubTracking, resolveTaskGithubTracking,
@@ -69,7 +70,11 @@ export function inlineTaskListFallback(
lines: string[], lines: string[],
opts: { maxChars?: number } = {}, opts: { maxChars?: number } = {},
): string { ): 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 { try {
const text = lines.join("\n"); const text = lines.join("\n");
if (text.length <= maxChars) { if (text.length <= maxChars) {

View File

@@ -201,6 +201,10 @@ describe("formatTaskListText", () => {
}); });
describe("clampTaskListText", () => { 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", () => { it("returns an empty string for empty input", () => {
expect(clampTaskListText([])).toBe(""); expect(clampTaskListText([])).toBe("");
}); });
@@ -264,6 +268,27 @@ describe("clampTaskListText", () => {
expect(clampTaskListText(lines).length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); 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", () => { it("handles a single over-budget line by returning a bounded truncation marker", () => {
const text = clampTaskListText(["FN-001 " + "x".repeat(200)], { maxChars: 40 }); const text = clampTaskListText(["FN-001 " + "x".repeat(200)], { maxChars: 40 });

View File

@@ -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"; 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: * 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. * 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. * 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( export function clampTaskListText(
lines: string[], lines: string[],

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; 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"; import { createPlanningBoardTools, resolveTaskListFormatter } from "../planning-board-tools.js";
function createStoreMock(overrides?: { function createStoreMock(overrides?: {
@@ -33,7 +33,7 @@ describe("fn_task_list resilience (FN-6573)", () => {
const formatter = resolveTaskListFormatter(coreNamespace); const formatter = resolveTaskListFormatter(coreNamespace);
const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd();
expect(text).toBeTruthy(); expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(12_000); expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
} }
}); });
}); });

View File

@@ -1,5 +1,5 @@
import * as fusionCore from "@fusion/core"; 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"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string; type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string;
@@ -12,7 +12,11 @@ export function inlineTaskListFallback(
lines: string[], lines: string[],
opts: { maxChars?: number } = {}, opts: { maxChars?: number } = {},
): string { ): 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 { try {
const text = lines.join("\n"); const text = lines.join("\n");
if (text.length <= maxChars) { if (text.length <= maxChars) {

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core"; 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 { import {
TriageProcessor, TriageProcessor,
buildSpecificationPrompt, buildSpecificationPrompt,
@@ -62,7 +62,7 @@ describe("fn_task_list resilience (FN-6573)", () => {
const formatter = resolveTaskListFormatter(coreNamespace); const formatter = resolveTaskListFormatter(coreNamespace);
const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd();
expect(text).toBeTruthy(); expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(12_000); expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
} }
}); });
}); });

View File

@@ -27,6 +27,7 @@ import {
findNearDuplicates, findNearDuplicates,
isNearDuplicateCanonicalInactive, isNearDuplicateCanonicalInactive,
applyFrontendUxCriteria, applyFrontendUxCriteria,
MAX_TASK_LIST_TEXT_CHARS,
type NearDuplicateCandidate, type NearDuplicateCandidate,
} from "@fusion/core"; } from "@fusion/core";
@@ -40,7 +41,11 @@ export function inlineTaskListFallback(
lines: string[], lines: string[],
opts: { maxChars?: number } = {}, opts: { maxChars?: number } = {},
): string { ): 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 { try {
const text = lines.join("\n"); const text = lines.join("\n");
if (text.length <= maxChars) { if (text.length <= maxChars) {