diff --git a/.changeset/fn-6570-task-list-resolve-fix.md b/.changeset/fn-6570-task-list-resolve-fix.md new file mode 100644 index 0000000000..a312822ebd --- /dev/null +++ b/.changeset/fn-6570-task-list-resolve-fix.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 23ad0ff87c..5987657139 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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] diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index e996d9d02a..f16c9bb1b8 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -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 () => { diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index ef122218dd..faf9ace4f6 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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 }, }; }, diff --git a/packages/core/src/__tests__/task-list-format.test.ts b/packages/core/src/__tests__/task-list-format.test.ts index b7edbc0b71..7d83e006f9 100644 --- a/packages/core/src/__tests__/task-list-format.test.ts +++ b/packages/core/src/__tests__/task-list-format.test.ts @@ -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; 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(""); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 79c20b1653..f4fca1b072 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/task-list-format.ts b/packages/core/src/task-list-format.ts index 00d87358d9..8a36a6cf5b 100644 --- a/packages/core/src/task-list-format.ts +++ b/packages/core/src/task-list-format.ts @@ -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); + } +} diff --git a/packages/dashboard/src/planning-board-tools.ts b/packages/dashboard/src/planning-board-tools.ts index 5f1c5b3098..4c0428c953 100644 --- a/packages/dashboard/src/planning-board-tools.ts +++ b/packages/dashboard/src/planning-board-tools.ts @@ -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: {}, }; }, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 1e055641c0..0c7051a13e 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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: {}, }; },