diff --git a/.changeset/fn-6573-task-list-format-resolve-fix.md b/.changeset/fn-6573-task-list-format-resolve-fix.md new file mode 100644 index 0000000000..e09a5b8432 --- /dev/null +++ b/.changeset/fn-6573-task-list-format-resolve-fix.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Resolve task-list text formatting defensively when an installed core package is missing the `formatTaskListText` runtime export, preserving `fn_task_list` output with a bounded inline fallback. diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index f16c9bb1b8..05d9f478a9 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -23,7 +23,7 @@ vi.mock("../commands/task.js", () => ({ runTaskPlan: vi.fn(), })); -import kbExtension from "../extension.js"; +import kbExtension, { resolveTaskListFormatter } from "../extension.js"; 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"; @@ -2767,7 +2767,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }, ); - it("degrades to bounded text when the clamp export is unavailable", () => { + it("degrades to bounded text when formatter exports are unavailable", () => { const boardLinesWithoutParams = [ "Planning (2):", ` FN-001 Planning task ${"x".repeat(6_000)}`, @@ -2782,14 +2782,21 @@ describe("fn pi extension (runnable structured-output regression slice)", () => ]; /* - 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. + FNXC:TaskListOutput 2026-06-17-07:32: + FN-6573 exercises the resolver seam called by the CLI surface because the extension harness imports @fusion/core before per-test mocks can safely replace the large cross-package namespace with a stale dist missing only task-list formatter exports. + These line sets mirror fn_task_list with params omitted and with column/limit provided, reproducing the prior missing `formatTaskListText` crash condition and the worse both-helpers-missing condition as bounded text instead of a throw. */ - 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); + const staleNamespaces = [ + { formatTaskListText: undefined, clampTaskListText: formatTaskListText }, + { formatTaskListText: undefined, clampTaskListText: undefined }, + ]; + for (const coreNamespace of staleNamespaces) { + const formatter = resolveTaskListFormatter(coreNamespace); + for (const lines of [boardLinesWithoutParams, boardLinesWithColumnAndLimit]) { + const text = formatter(lines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + } } }); }); diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index faf9ace4f6..708dc9c446 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -29,7 +29,6 @@ import { resolveSecretAccessPolicy, getProjectRootFromWorktree, resolveTaskGithubTracking, - formatTaskListText, type SecretScope, } from "@fusion/core"; import { @@ -60,6 +59,35 @@ import { spawn, type ChildProcess } from "node:child_process"; // ── Helpers ──────────────────────────────────────────────────────── +type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string; +type TaskListFormatter = ( + lines: string[], + opts?: { maxChars?: number; clamp?: TaskListClamp }, +) => string; + +export function inlineTaskListFallback( + lines: string[], + opts: { maxChars?: number } = {}, +): string { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 12_000)); + try { + const text = lines.join("\n"); + if (text.length <= maxChars) { + return text; + } + return text.slice(0, Math.max(0, maxChars - 1)) + "…"; + } catch { + return ""; + } +} + +export function resolveTaskListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter { + return typeof core.formatTaskListText === "function" + ? (core.formatTaskListText as TaskListFormatter) + : inlineTaskListFallback; +} + + /** #1403: display a column's label, falling back to the raw id for * workflow-defined custom columns that have no legacy label. */ function columnLabel(column: ColumnId): string { @@ -829,9 +857,13 @@ export default function kbExtension(pi: ExtensionAPI) { 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. + + FNXC:TaskListOutput 2026-06-17-07:25: + FN-6573 requires CLI fn_task_list to resolve formatTaskListText from the runtime @fusion/core namespace with a typeof guard and a self-contained bounded fallback. A stale @fusion/core dist missing the FN-6570 formatter export crashed ambient heartbeat agents as `(0 , _core.formatTaskListText) is not a function`; the tool must now return bounded text instead. */ + const formatter = resolveTaskListFormatter(fusionCore); return { - content: [{ type: "text", text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }).trimEnd() }], + content: [{ type: "text", text: formatter(lines, { clamp: fusionCore.clampTaskListText }).trimEnd() }], details: { count: tasks.length }, }; }, diff --git a/packages/dashboard/src/__tests__/planning-board-tools.test.ts b/packages/dashboard/src/__tests__/planning-board-tools.test.ts index f5b5d9bdd9..bbbe8ed484 100644 --- a/packages/dashboard/src/__tests__/planning-board-tools.test.ts +++ b/packages/dashboard/src/__tests__/planning-board-tools.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { TaskStore } from "@fusion/core"; -import { createPlanningBoardTools } from "../planning-board-tools.js"; +import { createPlanningBoardTools, resolveTaskListFormatter } from "../planning-board-tools.js"; function createStoreMock(overrides?: { listTasks?: TaskStore["listTasks"]; @@ -14,6 +14,31 @@ function createStoreMock(overrides?: { } as unknown as TaskStore; } + +describe("fn_task_list resilience (FN-6573)", () => { + it("returns bounded text when formatter exports are unavailable", () => { + const boardLines = [ + `FN-1 (todo): Dashboard duplicate check ${"x".repeat(6_000)}`, + `FN-2 (triage): Dashboard duplicate check ${"x".repeat(6_000)}`, + ]; + + /* + FNXC:TaskListOutput 2026-06-17-07:38: + FN-6573 drives the dashboard formatter resolver seam because the tool closure imports the live @fusion/core namespace at module load. The seam reproduces stale dist namespaces where formatTaskListText, or both task-list helpers, are absent and must still produce one bounded text block. + */ + for (const coreNamespace of [ + { formatTaskListText: undefined, clampTaskListText: () => "unused" }, + { formatTaskListText: undefined, clampTaskListText: undefined }, + ]) { + const formatter = resolveTaskListFormatter(coreNamespace); + const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(12_000); + } + }); +}); + + describe("createPlanningBoardTools", () => { it("fn_task_list does not throw TypeError on happy path and excludes done tasks", async () => { const store = createStoreMock({ diff --git a/packages/dashboard/src/planning-board-tools.ts b/packages/dashboard/src/planning-board-tools.ts index 4c0428c953..de45ebb649 100644 --- a/packages/dashboard/src/planning-board-tools.ts +++ b/packages/dashboard/src/planning-board-tools.ts @@ -1,7 +1,35 @@ import * as fusionCore from "@fusion/core"; -import { formatTaskListText, type TaskStore } from "@fusion/core"; +import type { TaskStore } from "@fusion/core"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; +type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string; +type TaskListFormatter = ( + lines: string[], + opts?: { maxChars?: number; clamp?: TaskListClamp }, +) => string; + +export function inlineTaskListFallback( + lines: string[], + opts: { maxChars?: number } = {}, +): string { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 12_000)); + try { + const text = lines.join("\n"); + if (text.length <= maxChars) { + return text; + } + return text.slice(0, Math.max(0, maxChars - 1)) + "…"; + } catch { + return ""; + } +} + +export function resolveTaskListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter { + return typeof core.formatTaskListText === "function" + ? (core.formatTaskListText as TaskListFormatter) + : inlineTaskListFallback; +} + export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] { const taskGetParams = { type: "object", @@ -39,9 +67,13 @@ export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] { 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. + + FNXC:TaskListOutput 2026-06-17-07:25: + FN-6573 requires dashboard fn_task_list to resolve formatTaskListText from the runtime @fusion/core namespace with a typeof guard and a self-contained bounded fallback. A stale @fusion/core dist missing the FN-6570 formatter export crashed ambient heartbeat agents as `(0 , _core.formatTaskListText) is not a function`; duplicate checks must now return bounded text instead. */ + const formatter = resolveTaskListFormatter(fusionCore); return { - content: [{ type: "text" as const, text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }) }], + content: [{ type: "text" as const, text: formatter(lines, { clamp: fusionCore.clampTaskListText }) }], details: {}, }; }, diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 18b9e4b7ed..286baea053 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -4,6 +4,7 @@ import { builtinSeamPrompt, renderTriagePolicyPlaceholders, resolveAgentPrompt } import { TriageProcessor, buildSpecificationPrompt, + resolveTaskListFormatter, readAttachmentContents, computeUserCommentFingerprint, } from "../triage.js"; @@ -42,6 +43,30 @@ vi.mock("@fusion/core", async (importOriginal) => { }); }); + +describe("fn_task_list resilience (FN-6573)", () => { + it("returns bounded text when formatter exports are unavailable", () => { + const boardLines = [ + `FN-1 (todo): Triage duplicate check ${"x".repeat(6_000)}`, + `FN-2 (triage): Triage duplicate check ${"x".repeat(6_000)}`, + ]; + + /* + FNXC:TaskListOutput 2026-06-17-07:38: + FN-6573 drives the engine triage formatter resolver seam because the tool closure imports the live @fusion/core namespace at module load. The seam reproduces stale dist namespaces where formatTaskListText, or both task-list helpers, are absent and must still produce one bounded text block. + */ + for (const coreNamespace of [ + { formatTaskListText: undefined, clampTaskListText: () => "unused" }, + { formatTaskListText: undefined, clampTaskListText: undefined }, + ]) { + const formatter = resolveTaskListFormatter(coreNamespace); + const text = formatter(boardLines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(12_000); + } + }); +}); + async function createTriageFixtureRoot(prefix: string): Promise { return mkdtemp(join(tmpdir(), prefix)); } diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 0c7051a13e..16e757e9d7 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -27,9 +27,37 @@ import { findNearDuplicates, isNearDuplicateCanonicalInactive, applyFrontendUxCriteria, - formatTaskListText, type NearDuplicateCandidate, } from "@fusion/core"; + +type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string; +type TaskListFormatter = ( + lines: string[], + opts?: { maxChars?: number; clamp?: TaskListClamp }, +) => string; + +export function inlineTaskListFallback( + lines: string[], + opts: { maxChars?: number } = {}, +): string { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? 12_000)); + try { + const text = lines.join("\n"); + if (text.length <= maxChars) { + return text; + } + return text.slice(0, Math.max(0, maxChars - 1)) + "…"; + } catch { + return ""; + } +} + +export function resolveTaskListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter { + return typeof core.formatTaskListText === "function" + ? (core.formatTaskListText as TaskListFormatter) + : inlineTaskListFallback; +} + import type { ImageContent } from "@earendil-works/pi-ai"; import { Type, type Static } from "@earendil-works/pi-ai"; import type { @@ -1475,9 +1503,13 @@ export class TriageProcessor { 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. + + FNXC:TaskListOutput 2026-06-17-07:25: + FN-6573 requires engine triage fn_task_list to resolve formatTaskListText from the runtime @fusion/core namespace with a typeof guard and a self-contained bounded fallback. A stale @fusion/core dist missing the FN-6570 formatter export crashed ambient heartbeat agents as `(0 , _core.formatTaskListText) is not a function`; duplicate detection must now return bounded text instead. */ + const formatter = resolveTaskListFormatter(fusionCore); return { - content: [{ type: "text" as const, text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }) }], + content: [{ type: "text" as const, text: formatter(lines, { clamp: fusionCore.clampTaskListText }) }], details: {}, }; },