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:
5
.changeset/fn-6492-task-list-text-bound.md
Normal file
5
.changeset/fn-6492-task-list-text-bound.md
Normal 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.
|
||||
@@ -22,7 +22,7 @@ vi.mock("../commands/task.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 { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const createTool = api.tools.get("fn_task_create")!;
|
||||
const result = await createTool.execute(
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveSecretAccessPolicy,
|
||||
getProjectRootFromWorktree,
|
||||
resolveTaskGithubTracking,
|
||||
clampTaskListText,
|
||||
type SecretScope,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -821,8 +822,12 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
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 {
|
||||
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
|
||||
content: [{ type: "text", text: clampTaskListText(lines).trimEnd() }],
|
||||
details: { count: tasks.length },
|
||||
};
|
||||
},
|
||||
|
||||
75
packages/core/src/__tests__/task-list-format.test.ts
Normal file
75
packages/core/src/__tests__/task-list-format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export { redactSecrets } from "./redact-secrets.js";
|
||||
export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js";
|
||||
export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.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 type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
|
||||
export {
|
||||
|
||||
45
packages/core/src/task-list-format.ts
Normal file
45
packages/core/src/task-list-format.ts
Normal 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)) + "…";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
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(", ")}]` : "";
|
||||
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 {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
content: [{ type: "text" as const, text: clampTaskListText(lines) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
findNearDuplicates,
|
||||
isNearDuplicateCanonicalInactive,
|
||||
applyFrontendUxCriteria,
|
||||
clampTaskListText,
|
||||
type NearDuplicateCandidate,
|
||||
} from "@fusion/core";
|
||||
import type { ImageContent } from "@earendil-works/pi-ai";
|
||||
@@ -1467,8 +1468,12 @@ export class TriageProcessor {
|
||||
: "";
|
||||
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 {
|
||||
content: [{ type: "text" as const, text: lines.join("\n") }],
|
||||
content: [{ type: "text" as const, text: clampTaskListText(lines) }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -100,6 +100,11 @@ export default defineConfig({
|
||||
// `pnpm test` stays snappy. CI picks them up via `test:slow`
|
||||
// / `test:all` invoked from the root `test:full` script.
|
||||
"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/**",
|
||||
"dist/**",
|
||||
/*
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user