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