FN-6570: guard task list formatting fallback

Prevent fn_task_list surfaces from crashing when the runtime core namespace lacks the clamp export.

- Add a shared defensive task-list formatter with a bounded fallback clamp.
- Route CLI, dashboard planning-board, and engine triage fn_task_list output through the formatter.
- Cover missing, throwing, and non-string clamp behavior in core and CLI tests.
- Document the resilient task-list formatting behavior and add a patch changeset.

Files changed:
 .changeset/fn-6570-task-list-resolve-fix.md        |  5 ++
 docs/cli-reference.md                              |  2 +-
 packages/cli/src/__tests__/extension.test.ts       | 57 +++++++++++++++++++++-
 packages/cli/src/extension.ts                      |  8 ++-
 .../core/src/__tests__/task-list-format.test.ts    | 49 ++++++++++++++++++-
 packages/core/src/index.ts                         |  2 +-
 packages/core/src/task-list-format.ts              | 35 +++++++++++++
 packages/dashboard/src/planning-board-tools.ts     |  8 ++-
 packages/engine/src/triage.ts                      |  8 ++-
 9 files changed, 164 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-6570

Fusion-Task-Lineage: 455f38a8-5e4f-4643-a3ce-088ef0c92b01
This commit is contained in:
gsxdsm
2026-06-17 06:14:10 -07:00
parent 5403a774de
commit a84a8e1793
9 changed files with 164 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn_task_list` crashes when the runtime `@fusion/core` formatter export is unavailable by resolving defensively and returning bounded fallback text.

View File

@@ -485,7 +485,7 @@ Use planning mode to turn a rough idea into a triage task through an interactive
When supported by your configured runtime/model provider, planning sessions can also use builtin `WebSearch` and `WebFetch` tools for live context gathering.
Planning sessions also have read-only board tools: `fn_task_list` (list active backlog tasks) and `fn_task_get` (read full task details, including PROMPT.md) so interviews can avoid duplicate in-flight plans and anchor questions to existing work. `fn_task_list` also accepts `includeDeleted: true` to surface soft-deleted blockers when diagnosing stalled dependency chains, and `fn_task_show` now auto-falls back to include soft-deleted tasks with a `[SOFT-DELETED at ...]` marker.
Planning sessions also have read-only board tools: `fn_task_list` (list active backlog tasks) and `fn_task_get` (read full task details, including PROMPT.md) so interviews can avoid duplicate in-flight plans and anchor questions to existing work. `fn_task_list` output is bounded and falls back to a defensive formatter if the runtime task-list clamp helper is unavailable, so board reads return text instead of failing during ambient planning or heartbeat checks. `fn_task_list` also accepts `includeDeleted: true` to surface soft-deleted blockers when diagnosing stalled dependency chains, and `fn_task_show` now auto-falls back to include soft-deleted tasks with a `[SOFT-DELETED at ...]` marker.
```bash
fn task plan [description]

View File

@@ -24,7 +24,7 @@ vi.mock("../commands/task.js", () => ({
}));
import kbExtension from "../extension.js";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS } from "@fusion/core";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js";
@@ -2552,6 +2552,35 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
});
describe("fn_task_list", () => {
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);
}
it("returns bounded text for omitted and provided column/limit params", async () => {
const store = new TaskStore(tmpDir);
await store.init();
try {
await store.createTask({ description: "Planning task one" });
await store.createTask({ description: "Todo task one", column: "todo" });
} finally {
store.close();
}
const listTool = api.tools.get("fn_task_list")!;
for (const [callId, params] of [
["list-all-default", {}],
["list-todo-default", { column: "todo" }],
["list-todo-large-limit", { column: "todo", limit: 50 }],
] as const) {
const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir));
expectSingleBoundedTextBlock(result);
expect(result.details.count).toBe(2);
}
});
it("keeps small column-filtered listings complete without the clamp marker", async () => {
const store = new TaskStore(tmpDir);
await store.init();
@@ -2737,6 +2766,32 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
}
},
);
it("degrades to bounded text when the clamp export is unavailable", () => {
const boardLinesWithoutParams = [
"Planning (2):",
` FN-001 Planning task ${"x".repeat(6_000)}`,
` FN-002 Planning task ${"x".repeat(6_000)}`,
"",
];
const boardLinesWithColumnAndLimit = [
"Todo (2):",
` FN-003 Todo task ${"x".repeat(6_000)}`,
" ... and 1 more",
"",
];
/*
FNXC:TaskListOutput 2026-06-17-05:55:
FN-6570 exercises the formatter boundary called by the CLI surface because the existing extension harness imports @fusion/core before per-test mocks can safely replace only clampTaskListText with a stale-dist missing export.
The two line sets mirror fn_task_list with omitted params and with column/limit provided, proving the surface path now receives bounded text instead of a crashing `(0 , _core.clampTaskListText) is not a function` call.
*/
for (const lines of [boardLinesWithoutParams, boardLinesWithColumnAndLimit]) {
const text = formatTaskListText(lines, { clamp: undefined }).trimEnd();
expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
}
});
});
it("returns structured details for invalid task assignment", async () => {

View File

@@ -1,6 +1,7 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import * as fusionCore from "@fusion/core";
import {
TaskStore,
COLUMNS,
@@ -28,7 +29,7 @@ import {
resolveSecretAccessPolicy,
getProjectRootFromWorktree,
resolveTaskGithubTracking,
clampTaskListText,
formatTaskListText,
type SecretScope,
} from "@fusion/core";
import {
@@ -825,9 +826,12 @@ export default function kbExtension(pi: ExtensionAPI) {
/*
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.
FNXC:TaskListOutput 2026-06-17-05:46:
FN-6570 resolves the clamp from the runtime @fusion/core namespace and lets formatTaskListText fall back when stale dist/interoperability paths omit clampTaskListText, preventing heartbeat board reads from crashing.
*/
return {
content: [{ type: "text", text: clampTaskListText(lines).trimEnd() }],
content: [{ type: "text", text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }).trimEnd() }],
details: { count: tasks.length },
};
},

View File

@@ -5,8 +5,9 @@ import { describe, expect, it } from "vitest";
import {
clampTaskListText as sourceBarrelClampTaskListText,
MAX_TASK_LIST_TEXT_CHARS as SOURCE_BARREL_MAX_TASK_LIST_TEXT_CHARS,
formatTaskListText as sourceBarrelFormatTaskListText,
} from "../index.js";
import { clampTaskListText, MAX_TASK_LIST_TEXT_CHARS } from "../task-list-format.js";
import { clampTaskListText, formatTaskListText, MAX_TASK_LIST_TEXT_CHARS } from "../task-list-format.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -15,6 +16,7 @@ type RuntimeCoreTaskListModule = {
COLUMN_LABELS: Record<string, string>;
MAX_TASK_LIST_TEXT_CHARS: number;
clampTaskListText: (lines: string[]) => string;
formatTaskListText?: (lines: string[]) => string;
};
type RuntimeTask = {
@@ -81,6 +83,7 @@ describe("@fusion/core dist barrel export wiring (FN-6515/FN-6535)", () => {
it("re-exports task-list formatting helpers from the source barrel", () => {
expect(typeof sourceBarrelClampTaskListText).toBe("function");
expect(typeof sourceBarrelFormatTaskListText).toBe("function");
expect(typeof SOURCE_BARREL_MAX_TASK_LIST_TEXT_CHARS).toBe("number");
});
@@ -90,6 +93,7 @@ describe("@fusion/core dist barrel export wiring (FN-6515/FN-6535)", () => {
const mod = await import(pathToFileURL(distIndex).href);
expect(typeof mod.clampTaskListText).toBe("function");
expect(typeof mod.formatTaskListText).toBe("function");
expect(typeof mod.MAX_TASK_LIST_TEXT_CHARS).toBe("number");
});
@@ -141,6 +145,49 @@ describe("@fusion/core dist barrel export wiring (FN-6515/FN-6535)", () => {
});
});
describe("formatTaskListText", () => {
it("returns an empty string for empty input", () => {
expect(formatTaskListText([])).toBe("");
});
it("uses the canonical clamp path for small input without a marker", () => {
const lines = ["Todo (2):", " FN-001 First task", " FN-002 Second task"];
expect(formatTaskListText(lines)).toBe(lines.join("\n"));
expect(formatTaskListText(lines)).not.toContain("truncated to fit");
});
it("uses the canonical clamp path for large input with the FN-6492 marker", () => {
const lines = Array.from({ length: 20 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(20)}`);
const text = formatTaskListText(lines, { maxChars: 150 });
expect(text.length).toBeLessThanOrEqual(150);
expect(text).toContain("truncated to fit; narrow with column/limit");
});
it("falls back to bounded text when the clamp helper is missing", () => {
const lines = Array.from({ length: 500 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(80)}`);
const text = formatTaskListText(lines, { clamp: undefined });
expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
});
it("falls back to bounded text when the clamp binding is not a function", () => {
const lines = ["FN-001 " + "x".repeat(200)];
const text = formatTaskListText(lines, {
maxChars: 40,
clamp: "not-a-function" as unknown as (lines: string[], opts?: { maxChars?: number }) => string,
});
expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(40);
expect(text.endsWith("…")).toBe(true);
});
});
describe("clampTaskListText", () => {
it("returns an empty string for empty input", () => {
expect(clampTaskListText([])).toBe("");

View File

@@ -20,7 +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 { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
export {

View File

@@ -43,3 +43,38 @@ export function clampTaskListText(
return marker.slice(0, Math.max(0, maxChars - 1)) + "…";
}
function fallbackClampTaskListText(lines: string[], maxChars: number): string {
const text = lines.join("\n");
if (text.length <= maxChars) {
return text;
}
return text.slice(0, Math.max(0, maxChars - 1)) + "…";
}
/**
* FNXC:TaskListOutput 2026-06-17-05:44:
* FN-6570 requires fn_task_list tool surfaces to resolve the formatter defensively because stale or mismatched @fusion/core builds can omit the clampTaskListText export and crash ambient heartbeat agents as `(0 , _core.clampTaskListText) is not a function`.
* Keep the canonical clamp as the normal path, but degrade to a bounded inline fallback so board listing tools return text instead of throwing.
*/
export function formatTaskListText(
lines: string[],
opts: {
maxChars?: number;
clamp?: (lines: string[], opts?: { maxChars?: number }) => string;
} = {},
): string {
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS));
const clamp = opts.clamp ?? clampTaskListText;
if (typeof clamp !== "function") {
return fallbackClampTaskListText(lines, maxChars);
}
try {
const text = clamp(lines, { maxChars });
return typeof text === "string" ? text : fallbackClampTaskListText(lines, maxChars);
} catch {
return fallbackClampTaskListText(lines, maxChars);
}
}

View File

@@ -1,4 +1,5 @@
import { clampTaskListText, type TaskStore } from "@fusion/core";
import * as fusionCore from "@fusion/core";
import { formatTaskListText, type TaskStore } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] {
@@ -35,9 +36,12 @@ export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] {
/*
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.
FNXC:TaskListOutput 2026-06-17-05:46:
FN-6570 keeps the planning-board fn_task_list surface resilient when runtime @fusion/core lacks clampTaskListText by passing the namespace binding through the defensive formatter fallback.
*/
return {
content: [{ type: "text" as const, text: clampTaskListText(lines) }],
content: [{ type: "text" as const, text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }) }],
details: {},
};
},

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as fusionCore from "@fusion/core";
import type {
TaskStore,
Task,
@@ -26,7 +27,7 @@ import {
findNearDuplicates,
isNearDuplicateCanonicalInactive,
applyFrontendUxCriteria,
clampTaskListText,
formatTaskListText,
type NearDuplicateCandidate,
} from "@fusion/core";
import type { ImageContent } from "@earendil-works/pi-ai";
@@ -1471,9 +1472,12 @@ export class TriageProcessor {
/*
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.
FNXC:TaskListOutput 2026-06-17-05:47:
FN-6570 guards the triage fn_task_list formatter against stale @fusion/core runtime namespaces where clampTaskListText is absent, so duplicate-detection board reads degrade to bounded text instead of throwing.
*/
return {
content: [{ type: "text" as const, text: clampTaskListText(lines) }],
content: [{ type: "text" as const, text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }) }],
details: {},
};
},