FN-6630: return text for empty task-list columns

Ensure filtered fn_task_list calls always return host-safe text for empty target columns.

- Add explicit empty-state copy for column filters with no matching tasks.
- Keep formatter fallback output non-empty before returning tool content.
- Cover empty active-column filters and existing small listings in CLI extension tests.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-6630-task-list-empty-column.md |  5 ++++
 packages/cli/src/__tests__/extension.test.ts | 36 +++++++++++++++++++++++++++-
 packages/cli/src/extension.ts                | 15 ++++++++++--
 3 files changed, 53 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6630

Fusion-Task-Lineage: e371bf11-5841-47d0-ac72-4cdf2f1716ba
This commit is contained in:
gsxdsm
2026-06-18 04:53:50 -07:00
parent 4929198b5a
commit 673a8a64c3
3 changed files with 53 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn_task_list` column filters so empty target columns return explicit text instead of an empty content block.

View File

@@ -23,7 +23,7 @@ vi.mock("../commands/task.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, COLUMN_LABELS } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { hasBuiltCoreDistBarrel } from "@fusion/test-utils";
@@ -2588,6 +2588,37 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
}
});
it("returns explicit text for empty active-column filters on a non-empty board", async () => {
const store = new TaskStore(tmpDir);
await store.init();
try {
await store.createTask({ description: "Finished task keeps the board non-empty", column: "done" });
} finally {
store.close();
}
const listTool = api.tools.get("fn_task_list")!;
for (const column of ["triage", "todo", "in-progress", "in-review"] as const) {
const result = await listTool.execute(
`empty-${column}`,
{ column },
undefined,
undefined,
makeCtx(tmpDir),
);
const text = result.content[0].text;
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
expect(result.content.some((block: any) => block.type === "image")).toBe(false);
expect(text).toBeTruthy();
expect(text.trim()).not.toBe("");
expect(text).toContain(COLUMN_LABELS[column]);
expect(text).toContain(column);
expect(result.details.count).toBe(1);
}
});
it("keeps small column-filtered listings complete without the clamp marker", async () => {
const store = new TaskStore(tmpDir);
await store.init();
@@ -2611,10 +2642,13 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
expect(result.content.some((block: any) => block.type === "image")).toBe(false);
expect(text).toBeTruthy();
expect(text.trim()).not.toBe("");
expect(text).toContain("Todo (2):");
expect(text).toContain("FN-001");
expect(text).toContain("FN-002");
expect(text).toContain("[deps: FN-001]");
expect(text).not.toContain("No tasks in Todo (todo).");
expect(text).not.toContain("truncated to fit; narrow with column/limit");
expect(result.details.count).toBe(2);
});

View File

@@ -854,9 +854,10 @@ export default function kbExtension(pi: ExtensionAPI) {
}
const perColumn = params.limit ?? 10;
const requestedColumn = params.column as ColumnId | undefined;
const lines: string[] = [];
for (const col of COLUMNS) {
if (params.column && params.column !== col) continue;
if (requestedColumn && requestedColumn !== col) continue;
const colTasks = tasks.filter((t) => t.column === col);
if (colTasks.length === 0) continue;
@@ -873,6 +874,10 @@ export default function kbExtension(pi: ExtensionAPI) {
lines.push("");
}
const emptyStateText = requestedColumn
? `No tasks in ${columnLabel(requestedColumn)} (${requestedColumn}).`
: "No matching tasks.";
/*
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.
@@ -882,10 +887,16 @@ export default function kbExtension(pi: ExtensionAPI) {
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.
FNXC:TaskListOutput 2026-06-18-04:46:
FN-6630 refines FN-6492 by requiring filtered fn_task_list calls against empty target columns to return explicit empty-state text. Host runtimes can imageify empty content blocks as `(see attached image)`, so this call site must never emit empty or whitespace-only text.
*/
const formatter = resolveTaskListFormatter(fusionCore);
const text = lines.length === 0
? emptyStateText
: formatter(lines, { clamp: fusionCore.clampTaskListText }).trimEnd();
return {
content: [{ type: "text", text: formatter(lines, { clamp: fusionCore.clampTaskListText }).trimEnd() }],
content: [{ type: "text", text: text.trim().length > 0 ? text : emptyStateText }],
details: { count: tasks.length },
};
},