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(),
|
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 { 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 type { WorkflowIr } from "@fusion/core";
|
||||||
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
|
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 = [
|
const boardLinesWithoutParams = [
|
||||||
"Planning (2):",
|
"Planning (2):",
|
||||||
` FN-001 Planning task ${"x".repeat(6_000)}`,
|
` 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:
|
FNXC:TaskListOutput 2026-06-17-07:32:
|
||||||
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.
|
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.
|
||||||
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.
|
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 staleNamespaces = [
|
||||||
const text = formatTaskListText(lines, { clamp: undefined }).trimEnd();
|
{ formatTaskListText: undefined, clampTaskListText: formatTaskListText },
|
||||||
expect(text).toBeTruthy();
|
{ formatTaskListText: undefined, clampTaskListText: undefined },
|
||||||
expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
|
];
|
||||||
|
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,
|
resolveSecretAccessPolicy,
|
||||||
getProjectRootFromWorktree,
|
getProjectRootFromWorktree,
|
||||||
resolveTaskGithubTracking,
|
resolveTaskGithubTracking,
|
||||||
formatTaskListText,
|
|
||||||
type SecretScope,
|
type SecretScope,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +59,35 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── 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
|
/** #1403: display a column's label, falling back to the raw id for
|
||||||
* workflow-defined custom columns that have no legacy label. */
|
* workflow-defined custom columns that have no legacy label. */
|
||||||
function columnLabel(column: ColumnId): string {
|
function columnLabel(column: ColumnId): string {
|
||||||
@@ -829,9 +857,13 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
FNXC:TaskListOutput 2026-06-17-05:46:
|
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.
|
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 {
|
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 },
|
details: { count: tasks.length },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { TaskStore } from "@fusion/core";
|
import type { TaskStore } from "@fusion/core";
|
||||||
import { createPlanningBoardTools } from "../planning-board-tools.js";
|
import { createPlanningBoardTools, resolveTaskListFormatter } from "../planning-board-tools.js";
|
||||||
|
|
||||||
function createStoreMock(overrides?: {
|
function createStoreMock(overrides?: {
|
||||||
listTasks?: TaskStore["listTasks"];
|
listTasks?: TaskStore["listTasks"];
|
||||||
@@ -14,6 +14,31 @@ function createStoreMock(overrides?: {
|
|||||||
} as unknown as TaskStore;
|
} 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", () => {
|
describe("createPlanningBoardTools", () => {
|
||||||
it("fn_task_list does not throw TypeError on happy path and excludes done tasks", async () => {
|
it("fn_task_list does not throw TypeError on happy path and excludes done tasks", async () => {
|
||||||
const store = createStoreMock({
|
const store = createStoreMock({
|
||||||
|
|||||||
@@ -1,7 +1,35 @@
|
|||||||
import * as fusionCore from "@fusion/core";
|
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";
|
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[] {
|
export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] {
|
||||||
const taskGetParams = {
|
const taskGetParams = {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -39,9 +67,13 @@ export function createPlanningBoardTools(store: TaskStore): ToolDefinition[] {
|
|||||||
|
|
||||||
FNXC:TaskListOutput 2026-06-17-05:46:
|
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.
|
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 {
|
return {
|
||||||
content: [{ type: "text" as const, text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }) }],
|
content: [{ type: "text" as const, text: formatter(lines, { clamp: fusionCore.clampTaskListText }) }],
|
||||||
details: {},
|
details: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { builtinSeamPrompt, renderTriagePolicyPlaceholders, resolveAgentPrompt }
|
|||||||
import {
|
import {
|
||||||
TriageProcessor,
|
TriageProcessor,
|
||||||
buildSpecificationPrompt,
|
buildSpecificationPrompt,
|
||||||
|
resolveTaskListFormatter,
|
||||||
readAttachmentContents,
|
readAttachmentContents,
|
||||||
computeUserCommentFingerprint,
|
computeUserCommentFingerprint,
|
||||||
} from "../triage.js";
|
} 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> {
|
async function createTriageFixtureRoot(prefix: string): Promise<string> {
|
||||||
return mkdtemp(join(tmpdir(), prefix));
|
return mkdtemp(join(tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,37 @@ import {
|
|||||||
findNearDuplicates,
|
findNearDuplicates,
|
||||||
isNearDuplicateCanonicalInactive,
|
isNearDuplicateCanonicalInactive,
|
||||||
applyFrontendUxCriteria,
|
applyFrontendUxCriteria,
|
||||||
formatTaskListText,
|
|
||||||
type NearDuplicateCandidate,
|
type NearDuplicateCandidate,
|
||||||
} from "@fusion/core";
|
} 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 { ImageContent } from "@earendil-works/pi-ai";
|
||||||
import { Type, type Static } from "@earendil-works/pi-ai";
|
import { Type, type Static } from "@earendil-works/pi-ai";
|
||||||
import type {
|
import type {
|
||||||
@@ -1475,9 +1503,13 @@ export class TriageProcessor {
|
|||||||
|
|
||||||
FNXC:TaskListOutput 2026-06-17-05:47:
|
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.
|
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 {
|
return {
|
||||||
content: [{ type: "text" as const, text: formatTaskListText(lines, { clamp: fusionCore.clampTaskListText }) }],
|
content: [{ type: "text" as const, text: formatter(lines, { clamp: fusionCore.clampTaskListText }) }],
|
||||||
details: {},
|
details: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user