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:
5
.changeset/fn-6629-task-list-budget.md
Normal file
5
.changeset/fn-6629-task-list-budget.md
Normal 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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user