FN-6492: clamp task list text output

Bound fn_task_list responses now stay as plain text across board-tool surfaces.\n\n- Add a shared task-list text clamp with an explicit truncation marker.\n- Route CLI, dashboard planning-board, and engine triage task-list output through the shared formatter.\n- Cover bounded and column-filtered output with core and CLI regression tests.\n- Record the patch changeset and quarantine the observed engine CLI executor flake.\n\nFiles changed:\n .changeset/fn-6492-task-list-text-bound.md         |  5 ++\n packages/cli/src/__tests__/extension.test.ts       | 79 +++++++++++++++++++++-\n packages/cli/src/extension.ts                      |  7 +-\n .../core/src/__tests__/task-list-format.test.ts    | 75 ++++++++++++++++++++\n packages/core/src/index.ts                         |  1 +\n packages/core/src/task-list-format.ts              | 45 ++++++++++++\n packages/dashboard/src/planning-board-tools.ts     |  8 ++-\n packages/engine/src/triage.ts                      |  7 +-\n packages/engine/vitest.config.ts                   |  5 ++\n scripts/lib/test-quarantine.json                   |  5 ++\n 10 files changed, 232 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-6492

Fusion-Task-Lineage: 8b347efb-bc17-4bbe-937c-4f07c63df318
This commit is contained in:
gsxdsm
2026-06-16 19:31:33 -07:00
parent 198fb17277
commit 0db81349bb
10 changed files with 232 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Bound `fn_task_list` text output across CLI, dashboard, and engine tool surfaces so oversized board listings remain plain text with an explicit truncation marker instead of overflowing host response budgets.

View File

@@ -22,7 +22,7 @@ vi.mock("../commands/task.js", () => ({
})); }));
import kbExtension from "../extension.js"; import kbExtension from "../extension.js";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES } from "@fusion/core"; import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core"; import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli"; import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js"; import { runTaskPlan } from "../commands/task.js";
@@ -2547,6 +2547,83 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain(result.details.taskId); expect(result.content[0].text).toContain(result.details.taskId);
}); });
describe("fn_task_list", () => {
it("keeps small column-filtered listings complete without the clamp marker", async () => {
const store = new TaskStore(tmpDir);
await store.init();
try {
const first = await store.createTask({ description: "Small todo task one", column: "todo" });
await store.createTask({ description: "Small todo task two", column: "todo", dependencies: [first.id] });
} finally {
store.close();
}
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"list-small-todo",
{ column: "todo", limit: 50 },
undefined,
undefined,
makeCtx(tmpDir),
);
const text = result.content[0].text;
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
expect(result.content.some((block: any) => block.type === "image")).toBe(false);
expect(text).toContain("Todo (2):");
expect(text).toContain("FN-001");
expect(text).toContain("FN-002");
expect(text).toContain("[deps: FN-001]");
expect(text).not.toContain("truncated to fit; narrow with column/limit");
expect(result.details.count).toBe(2);
});
it("bounds large column-filtered listings as a single plain-text block", async () => {
const store = new TaskStore(tmpDir);
await store.init();
try {
const first = await store.createTask({
title: `Todo task 001 ${"x".repeat(260)}`,
description: "Large todo task 001",
column: "todo",
});
for (let i = 2; i <= 60; i += 1) {
await store.createTask({
title: `Todo task ${String(i).padStart(3, "0")} ${"x".repeat(260)}`,
description: `Large todo task ${String(i).padStart(3, "0")}`,
column: "todo",
dependencies: [first.id],
});
}
} finally {
store.close();
}
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"list-large-todo",
{ column: "todo", limit: 50 },
undefined,
undefined,
makeCtx(tmpDir),
);
const text = result.content[0].text;
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
expect(result.content.some((block: any) => block.type === "image")).toBe(false);
expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(text).toContain("Todo (60):");
expect(text).toContain("FN-001");
expect(text).toContain("FN-002");
expect(text).toContain("[deps: FN-001]");
expect(text).toContain("truncated to fit; narrow with column/limit");
expect(result.details.count).toBe(60);
});
});
it("returns structured details for invalid task assignment", async () => { it("returns structured details for invalid task assignment", async () => {
const createTool = api.tools.get("fn_task_create")!; const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute( const result = await createTool.execute(

View File

@@ -28,6 +28,7 @@ import {
resolveSecretAccessPolicy, resolveSecretAccessPolicy,
getProjectRootFromWorktree, getProjectRootFromWorktree,
resolveTaskGithubTracking, resolveTaskGithubTracking,
clampTaskListText,
type SecretScope, type SecretScope,
} from "@fusion/core"; } from "@fusion/core";
import { import {
@@ -821,8 +822,12 @@ export default function kbExtension(pi: ExtensionAPI) {
lines.push(""); lines.push("");
} }
/*
FNXC:TaskListOutput 2026-06-16-17:47:
FN-6492 routes CLI fn_task_list through the shared clamp so large column-filtered board reads remain text-only instead of being converted to host attachments.
*/
return { return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }], content: [{ type: "text", text: clampTaskListText(lines).trimEnd() }],
details: { count: tasks.length }, details: { count: tasks.length },
}; };
}, },

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { clampTaskListText, MAX_TASK_LIST_TEXT_CHARS } from "../task-list-format.js";
describe("clampTaskListText", () => {
it("returns an empty string for empty input", () => {
expect(clampTaskListText([])).toBe("");
});
it("returns small input unchanged without a marker", () => {
const lines = ["Todo (2):", " FN-001 First task", " FN-002 Second task"];
expect(clampTaskListText(lines)).toBe(lines.join("\n"));
expect(clampTaskListText(lines)).not.toContain("truncated to fit");
});
it("truncates large input to the budget with an accurate dropped-line marker", () => {
const lines = [
"Todo (5):",
" FN-001 Task one",
" FN-002 Task two",
" FN-003 Task three",
" FN-004 Task four",
" FN-005 Task five",
];
const text = clampTaskListText(lines, { maxChars: 95 });
expect(text.length).toBeLessThanOrEqual(95);
expect(text).toContain("Todo (5):");
expect(text).toContain("FN-001");
expect(text).toContain("... and 4 more tasks (truncated to fit; narrow with column/limit)");
});
it("never splits retained lines mid-line", () => {
const lines = [
"Todo (4):",
" FN-001 Retain me whole",
" FN-002 Retain me whole too",
" FN-003 Drop me whole",
" FN-004 Drop me whole too",
];
const text = clampTaskListText(lines, { maxChars: 105 });
const outputLines = text.split("\n");
expect(outputLines).toEqual([
"Todo (4):",
" FN-001 Retain me whole",
"... and 3 more tasks (truncated to fit; narrow with column/limit)",
]);
});
it("honors a custom maxChars budget", () => {
const lines = Array.from({ length: 20 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(20)}`);
const text = clampTaskListText(lines, { maxChars: 150 });
expect(text.length).toBeLessThanOrEqual(150);
expect(text).toContain("truncated to fit");
});
it("keeps default output within the exported budget", () => {
const lines = Array.from({ length: 500 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(80)}`);
expect(clampTaskListText(lines).length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
});
it("handles a single over-budget line by returning a bounded truncation marker", () => {
const text = clampTaskListText(["FN-001 " + "x".repeat(200)], { maxChars: 40 });
expect(text.length).toBeLessThanOrEqual(40);
expect(text).toMatch(/^\.\.\. and 1 more tas/);
expect(text.endsWith("…")).toBe(true);
});
});

View File

@@ -20,6 +20,7 @@ export { redactSecrets } from "./redact-secrets.js";
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js"; export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js";
export * from "./frontend-ux-policy.js"; export * from "./frontend-ux-policy.js";
export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText } from "./task-list-format.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export { export {

View File

@@ -0,0 +1,45 @@
export const MAX_TASK_LIST_TEXT_CHARS = 12_000;
const TRUNCATION_HINT = "truncated to fit; narrow with column/limit";
function markerLine(droppedCount: number): string {
return `... and ${droppedCount} more tasks (${TRUNCATION_HINT})`;
}
function joinWithMarker(lines: string[], marker: string): string {
return [...lines, marker].join("\n");
}
/**
* 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.
*/
export function clampTaskListText(
lines: string[],
opts: { maxChars?: number } = {},
): string {
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS));
const text = lines.join("\n");
if (text.length <= maxChars) {
return text;
}
const droppedTotal = lines.length;
let kept = lines.slice();
while (kept.length > 0) {
const droppedCount = droppedTotal - kept.length;
const candidate = joinWithMarker(kept, markerLine(droppedCount));
if (candidate.length <= maxChars) {
return candidate;
}
kept = kept.slice(0, -1);
}
const marker = markerLine(droppedTotal);
if (marker.length <= maxChars) {
return marker;
}
return marker.slice(0, Math.max(0, maxChars - 1)) + "…";
}

View File

@@ -1,4 +1,4 @@
import type { TaskStore } from "@fusion/core"; import { clampTaskListText, type TaskStore } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] { export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] {
@@ -32,8 +32,12 @@ export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] {
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : ""; const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
return `${t.id} (${t.column}): ${desc}${deps}`; return `${t.id} (${t.column}): ${desc}${deps}`;
}); });
/*
FNXC:TaskListOutput 2026-06-16-17:47:
FN-6492 keeps dashboard planning-board duplicate checks within the shared plain-text budget so large boards stay readable to non-vision agents.
*/
return { return {
content: [{ type: "text" as const, text: lines.join("\n") }], content: [{ type: "text" as const, text: clampTaskListText(lines) }],
details: {}, details: {},
}; };
}, },

View File

@@ -26,6 +26,7 @@ import {
findNearDuplicates, findNearDuplicates,
isNearDuplicateCanonicalInactive, isNearDuplicateCanonicalInactive,
applyFrontendUxCriteria, applyFrontendUxCriteria,
clampTaskListText,
type NearDuplicateCandidate, type NearDuplicateCandidate,
} from "@fusion/core"; } from "@fusion/core";
import type { ImageContent } from "@earendil-works/pi-ai"; import type { ImageContent } from "@earendil-works/pi-ai";
@@ -1467,8 +1468,12 @@ export class TriageProcessor {
: ""; : "";
return `${t.id} (${t.column}): ${desc}${deps}`; return `${t.id} (${t.column}): ${desc}${deps}`;
}); });
/*
FNXC:TaskListOutput 2026-06-16-17:47:
FN-6492 keeps engine triage duplicate-detection listings bounded with the shared fn_task_list text clamp so large active boards never require attachment/image fallback.
*/
return { return {
content: [{ type: "text" as const, text: lines.join("\n") }], content: [{ type: "text" as const, text: clampTaskListText(lines) }],
details: {}, details: {},
}; };
}, },

View File

@@ -100,6 +100,11 @@ export default defineConfig({
// `pnpm test` stays snappy. CI picks them up via `test:slow` // `pnpm test` stays snappy. CI picks them up via `test:slow`
// / `test:all` invoked from the root `test:full` script. // / `test:all` invoked from the root `test:full` script.
"src/**/*.slow.test.ts", "src/**/*.slow.test.ts",
"src/__tests__/cli-agent-executor.test.ts",
/*
FNXC:EngineTests 2026-06-16-19:05:
FN-6492 verification caught cli-agent-executor as a package-lane-only flake: the hard-cancel assertion failed once and left an ENOTEMPTY temp hook directory, then the file passed in isolation. Quarantine the whole file under the deletion ratchet instead of weakening timing or process assertions.
*/
"node_modules/**", "node_modules/**",
"dist/**", "dist/**",
/* /*

View File

@@ -10,6 +10,11 @@
"file": "packages/dashboard/src/__tests__/github-tracking-hook.test.ts", "file": "packages/dashboard/src/__tests__/github-tracking-hook.test.ts",
"reason": "FN-6496 merge verification: pnpm test failed in dashboard-api-quality-backfill with ENOTEMPTY while removing a temp task directory; isolated rerun of the file passed, so classify as unrelated cleanup flake. Failing command: pnpm test; confirming command: pnpm --filter @fusion/dashboard exec vitest run src/__tests__/github-tracking-hook.test.ts --reporter=dot --silent=passed-only.", "reason": "FN-6496 merge verification: pnpm test failed in dashboard-api-quality-backfill with ENOTEMPTY while removing a temp task directory; isolated rerun of the file passed, so classify as unrelated cleanup flake. Failing command: pnpm test; confirming command: pnpm --filter @fusion/dashboard exec vitest run src/__tests__/github-tracking-hook.test.ts --reporter=dot --silent=passed-only.",
"quarantinedAt": "2026-06-16" "quarantinedAt": "2026-06-16"
},
{
"file": "packages/engine/src/__tests__/cli-agent-executor.test.ts",
"reason": "FN-6492 verification observed the hard-cancel CLI session test fail only in the full @fusion/engine package lane (activeCliTaskSessions false plus ENOTEMPTY temp cleanup), while an immediate file-specific rerun passed; quarantined as a concurrency/temp-cleanup flake per the deletion ratchet.",
"quarantinedAt": "2026-06-16"
} }
] ]
} }