FN-6573: guard task-list formatter resolution

Make fn_task_list surfaces tolerate stale core runtimes without crashing.

- Resolve formatTaskListText from the runtime @fusion/core namespace with a typeof guard.
- Add bounded inline fallback formatting for CLI, dashboard planning-board, and engine triage task-list tools.
- Cover missing formatter exports with regression tests and add a published package changeset.

Files changed:
 .changeset/fn-6573-task-list-format-resolve-fix.md |  5 +++
 packages/cli/src/__tests__/extension.test.ts       | 25 +++++++++------
 packages/cli/src/extension.ts                      | 36 ++++++++++++++++++++--
 .../src/__tests__/planning-board-tools.test.ts     | 27 +++++++++++++++-
 packages/dashboard/src/planning-board-tools.ts     | 36 ++++++++++++++++++++--
 packages/engine/src/__tests__/triage.test.ts       | 25 +++++++++++++++
 packages/engine/src/triage.ts                      | 36 ++++++++++++++++++++--
 7 files changed, 174 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-6573

Fusion-Task-Lineage: 64577f09-5ca5-426e-a453-a91e6c2c3aee
This commit is contained in:
gsxdsm
2026-06-17 08:04:35 -07:00
parent 265d9ec4d3
commit 593ebac85c
7 changed files with 174 additions and 16 deletions

View File

@@ -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.

View File

@@ -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);
}
}
});
});

View File

@@ -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 },
};
},

View File

@@ -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({

View File

@@ -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: {},
};
},

View File

@@ -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<string> {
return mkdtemp(join(tmpdir(), prefix));
}

View File

@@ -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: {},
};
},