Files
fusion/packages/engine/src/__tests__/agent-task-read-tools.test.ts
gsxdsm 3d26d4e74b FN-7127: standardize task show tool naming
Unify task-show references so planning, triage, docs, and telemetry use fn_task_show consistently.

- Replace legacy fn_task_get guidance and examples with fn_task_show across agent prompts, mission planning, triage, and docs.
- Keep fn_task_get as a deprecated read-only recognition alias for historical or in-flight calls.
- Update tests and changesets to cover the unified tool name and usage telemetry.

Files changed:
 .changeset/FN-7118-shared-task-read-tools.md       |  2 +-
 .changeset/FN-7127-unify-task-show-tool-name.md    |  7 ++++++
 docs/cli-reference.md                              |  2 +-
 docs/missions.md                                   |  2 +-
 .../cli/skill/fusion/references/engine-tools.md    |  2 +-
 packages/core/src/__tests__/usage-events.test.ts   |  1 +
 packages/core/src/agent-prompts.ts                 | 12 +++++-----
 .../__tests__/milestone-slice-interview.test.ts    |  2 +-
 .../src/__tests__/mission-interview.test.ts        |  2 +-
 .../src/__tests__/planning-board-tools.test.ts     |  6 ++---
 .../dashboard/src/milestone-slice-interview.ts     |  4 ++--
 packages/dashboard/src/mission-interview.ts        |  2 +-
 packages/dashboard/src/planning-board-tools.ts     | 10 +++++---
 packages/dashboard/src/planning.ts                 |  2 +-
 .../src/__tests__/agent-task-read-tools.test.ts    | 28 +++++++++++++++-------
 packages/engine/src/__tests__/triage.test.ts       |  6 ++---
 packages/engine/src/agent-tools.ts                 |  5 +++-
 packages/engine/src/gating-classifications.ts      |  8 +++++--
 packages/engine/src/triage.ts                      | 12 ++++++----
 19 files changed, 74 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-7127

Fusion-Task-Lineage: adf5a127-2741-4c88-97b3-aab239ab3960

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 12:25:53 -07:00

171 lines
7.9 KiB
TypeScript

import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import { MAX_TASK_LIST_TEXT_CHARS, type AgentPermissionPolicy, type Task, type TaskDetail, type TaskStore } from "@fusion/core";
import { createPlanningBoardTools } from "../../../dashboard/src/planning-board-tools.js";
import { createTaskReadTools } from "../agent-tools.js";
import { HeartbeatMonitor } from "../agent-heartbeat.js";
import { evaluateAgentActionGate } from "../agent-action-gate.js";
import { COORDINATION_EXEMPT_TOOLS, READONLY_FN_TOOLS } from "../gating-classifications.js";
import { classifyPermanentAgentToolCall } from "../permanent-agent-gating.js";
import { TriageProcessor } from "../triage.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const lockedDownPolicy: AgentPermissionPolicy = {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
},
};
type TaskReadResult = Awaited<ReturnType<ReturnType<typeof createTaskReadTools>[number]["execute"]>>;
function textOf(result: TaskReadResult): string {
expect(result.content).toHaveLength(1);
expect(result.content[0]).toMatchObject({ type: "text" });
const text = result.content[0]?.text ?? "";
expect(text.trim().length).toBeGreaterThan(0);
return text;
}
function task(overrides: Partial<Task> & Pick<Task, "id">): Task {
return {
id: overrides.id,
title: overrides.title,
description: overrides.description ?? `Description for ${overrides.id}`,
column: overrides.column ?? "todo",
dependencies: overrides.dependencies ?? [],
steps: overrides.steps ?? [],
currentStep: overrides.currentStep ?? 0,
...overrides,
} as Task;
}
function taskDetail(overrides: Partial<TaskDetail> & Pick<TaskDetail, "id">): TaskDetail {
return {
...task(overrides),
prompt: overrides.prompt ?? "# Prompt body",
} as TaskDetail;
}
function createStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
listTasks: vi.fn(async () => []),
searchTasks: vi.fn(async () => []),
getTask: vi.fn(async (id: string) => taskDetail({ id })),
getSettings: vi.fn(async () => ({})),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function toolNames(tools: Array<{ name: string }>): string[] {
return tools.map((tool) => tool.name);
}
function extractRegisteredCliTaskReadNames(): string[] {
const source = readFileSync(resolve(__dirname, "../../../cli/src/extension.ts"), "utf8");
return [...source.matchAll(/name:\s*"(fn_task_(?:list|show|search|get))"/g)].map((match) => match[1]!);
}
describe("shared task read tools", () => {
it("returns the canonical heartbeat task-read names in order", () => {
expect(toolNames(createTaskReadTools(createStore()))).toEqual([
"fn_task_list",
"fn_task_show",
"fn_task_search",
]);
});
it("returns non-empty bounded text for empty, populated, no-match, and oversized boards", async () => {
const emptyTools = createTaskReadTools(createStore());
expect(textOf(await emptyTools[0]!.execute("list-empty", {}))).toBe("No active tasks.");
expect(textOf(await emptyTools[2]!.execute("search-empty", { query: "missing" }))).toBe("No tasks matched.");
const populatedStore = createStore({
listTasks: vi.fn(async () => [
task({ id: "FN-001", title: "Active task", column: "todo", dependencies: ["FN-000"] }),
task({ id: "FN-002", title: "Done task", column: "done" }),
]),
searchTasks: vi.fn(async () => [task({ id: "FN-003", title: "Search hit", column: "done" })]),
getTask: vi.fn(async () => taskDetail({ id: "FN-004", title: "Show me", prompt: "# Prompt" })),
});
const populatedTools = createTaskReadTools(populatedStore);
const listText = textOf(await populatedTools[0]!.execute("list-populated", {}));
expect(listText).toContain("FN-001 (todo): Active task [deps: FN-000]");
expect(listText).not.toContain("FN-002");
expect(textOf(await populatedTools[1]!.execute("show-populated", { id: "FN-004" }))).toContain("PROMPT.md:");
expect(textOf(await populatedTools[2]!.execute("search-populated", { query: "search", includeDone: true }))).toContain("FN-003 (done): Search hit");
const oversizedTasks = Array.from({ length: 120 }, (_, index) => task({
id: `FN-${String(index + 100).padStart(3, "0")}`,
title: `Oversized ${index} ${"x".repeat(120)}`,
column: "todo",
}));
const oversizedStore = createStore({
listTasks: vi.fn(async () => oversizedTasks),
searchTasks: vi.fn(async () => oversizedTasks),
});
const oversizedTools = createTaskReadTools(oversizedStore);
const oversizedListText = textOf(await oversizedTools[0]!.execute("list-oversized", {}));
const oversizedSearchText = textOf(await oversizedTools[2]!.execute("search-oversized", { query: "oversized" }));
expect(oversizedListText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(oversizedSearchText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(oversizedListText).toContain("truncated to fit");
});
it("exposes task reads through the shared heartbeat helper and task-scoped heartbeat tools", () => {
const monitor = new HeartbeatMonitor({ store: {} as never, taskStore: createStore(), rootDir: "/tmp/fn-test" });
const sharedNames = toolNames((monitor as unknown as { createSharedHeartbeatWorkTools: (store: TaskStore) => Array<{ name: string }> }).createSharedHeartbeatWorkTools(createStore()));
expect(sharedNames.slice(0, 3)).toEqual(["fn_task_list", "fn_task_show", "fn_task_search"]);
const taskScopedNames = toolNames(monitor.createHeartbeatTools("agent-1", createStore(), "FN-001"));
expect(taskScopedNames).toEqual(expect.arrayContaining(["fn_task_list", "fn_task_show", "fn_task_search"]));
});
it("positively classifies all task-read names as read-only", () => {
for (const toolName of ["fn_task_search", "fn_task_get", "fn_task_list", "fn_task_show"] as const) {
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(true);
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
const decision = evaluateAgentActionGate({ agentId: "agent-1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
expect(decision).toMatchObject({ disposition: "allow", category: "exempt", operation: toolName });
}
});
it("pins per-surface task-read tool name parity on canonical fn_task_show", () => {
const triageProcessor = new TriageProcessor(createStore() as never, "/tmp/fn-test");
const triageNames = toolNames((triageProcessor as unknown as { createTriageTools: (opts: unknown) => Array<{ name: string }> }).createTriageTools({
parentTaskId: "FN-TRIAGE",
allowTaskCreate: true,
createdSubtasksRef: { current: [] },
})).filter((name) => name.startsWith("fn_task_") && name !== "fn_task_create");
const surfaces = {
triage: triageNames,
planningBoard: toolNames(createPlanningBoardTools(createStore())).filter((name) => name.startsWith("fn_task_")),
cliExtension: extractRegisteredCliTaskReadNames(),
sharedFactory: toolNames(createTaskReadTools(createStore())),
};
expect(surfaces).toEqual({
triage: ["fn_task_list", "fn_task_search", "fn_task_show"],
planningBoard: ["fn_task_list", "fn_task_show"],
cliExtension: ["fn_task_list", "fn_task_show"],
sharedFactory: ["fn_task_list", "fn_task_show", "fn_task_search"],
});
const deprecatedGetName = ["fn_task", "get"].join("_");
for (const names of Object.values(surfaces)) {
expect(names).toContain("fn_task_show");
expect(names).not.toContain(deprecatedGetName);
}
});
});