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