Files
fusion/packages/cli/src/__tests__/extension.test.ts
gsxdsm 21fb8f6786 FN-7802: recover phantom-worktree tasks stuck merge-active with scopeOverride
Fixes phantom-worktree context bleed where the engine refused to start a
coding agent in a missing worktree for in-review/merge-active tasks even
when scopeOverride=1, stranding them past the normal recovery paths and
retry budget.

- Add isMergeActiveMissingWorktreeSessionStartFailure/isInReviewMissingWorktreeSessionStartFailure classifiers and MERGE_ACTIVE_MISSING_WORKTREE_STATUSES (merging/merging-pr/merging-fix) in restart-recovery-coordinator.ts, exported from @fusion/engine.
- Self-healing: reorder missing-worktree-review-failures sweep earlier, extend the in-review sweep to also match merge-active missing-worktree failures with a triple-proof-guarded, bounded (recoveryRetryCount) stale-metadata clear and fresh session-start retry budget reset.
- Self-healing: extend scopeOverride worktree-metadata reconciliation to safely clear phantom worktree/branch/session metadata for in-review tasks stuck in a merge-active sub-status, narrowly scoped to avoid clobbering genuinely live in-progress/mid-step tasks (FN-5256 guard preserved).
- CLI (task.ts), pi extension (extension.ts), and dashboard route (register-task-workflow-routes.ts) retry paths now bypass the merge-active status gate via a signature-only check, clearing worktree/branch/sessionFile and requeuing to todo while preserving progress.
- Add regression coverage across self-healing.test.ts, restart-recovery-coordinator.test.ts, extension.test.ts, task-retry.test.ts, and routes-tasks-ops.test.ts; update mockCoreEngine.ts test scaffolding.
- Update docs/architecture.md, docs/self-healing-backward-move-audit.md, docs/task-management.md, and AGENTS.md to describe the new merge-active missing-worktree recovery behavior.
- Add changeset (patch) for @runfusion/fusion.

Files changed:
 .changeset/fn-7802-phantom-worktree-merge-active-recovery.md      |   7 +
 AGENTS.md                                                          |   1 +
 docs/architecture.md                                               |   4 +-
 docs/self-healing-backward-move-audit.md                           |   5 +-
 docs/task-management.md                                            |   2 +-
 packages/cli/src/__tests__/extension.test.ts                       |  64 +++++
 packages/cli/src/__tests__/task-retry.test.ts                      |  49 ++++
 packages/cli/src/commands/task.ts                                  |  28 +-
 packages/cli/src/extension.ts                                      |  26 +-
 packages/dashboard/src/__tests__/routes-tasks-ops.test.ts          |  52 ++++
 packages/dashboard/src/routes/register-task-workflow-routes.ts     |  25 +-
 packages/dashboard/src/test/mockCoreEngine.ts                      |  11 +
 packages/engine/src/__tests__/restart-recovery-coordinator.test.ts |  20 ++
 packages/engine/src/__tests__/self-healing.test.ts                 | 297 +++++++++++++++++++++
 packages/engine/src/index.ts                                       |  13 +
 packages/engine/src/restart-recovery-coordinator.ts                |  19 +-
 packages/engine/src/self-healing.ts                                | 157 +++++++++--
 17 files changed, 744 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-7802

Fusion-Task-Lineage: 5897105b-6b5c-49d5-a8e8-519902182861

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-10 22:25:21 -07:00

4605 lines
167 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
/*
FNXC:CliTests 2026-06-14-01:22:
FN-6430 rescues the extension suite by fixing shared HOME isolation and closing research stores in the active slice, not by preserving the older file-wide timeout bump.
Keep this file on the default 5s Vitest timeout so future slow seams are narrowed or quarantined instead of hidden.
*/
vi.mock("@fusion/core/gh-cli", () => ({
isGhAvailable: vi.fn(() => true),
isGhAuthenticated: vi.fn(() => true),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((error: unknown) => (error instanceof Error ? error.message : String(error))),
}));
vi.mock("../commands/task.js", () => ({
runTaskPlan: vi.fn(),
}));
import kbExtension, { closeCachedStores, resolveTaskListFormatter } from "../extension.js";
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 { runTaskPlan } from "../commands/task.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Mock ExtensionAPI that captures registrations ──────────────────
interface RegisteredTool {
name: string;
label: string;
description: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
interface RegisteredCommand {
description: string;
handler: (args: string, ctx: any) => Promise<void>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
const commands = new Map<string, RegisteredCommand>();
const events = new Map<string, Function>();
const api = {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand(name: string, def: any) {
commands.set(name, def);
},
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
on(event: string, handler: Function) {
events.set(event, handler);
},
tools,
commands,
events,
};
return api as any;
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
async function seedAgent(
cwd: string,
overrides: { ephemeral?: boolean; name?: string } = {},
): Promise<string> {
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
await agentStore.init();
try {
const agent = await agentStore.createAgent({
name: overrides.name ?? "test-agent",
role: "executor",
metadata: overrides.ephemeral ? { agentKind: "task-worker" } : {},
});
return agent.id;
} finally {
agentStore.close();
}
}
function linearWorkflowIr(name: string): WorkflowIr {
return {
version: "v2",
name,
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{
id: "lint",
kind: "optional-group",
column: "todo",
config: {
name: "Lint",
defaultOn: true,
template: { nodes: [{ id: "lint-step", kind: "gate", config: { name: "Lint", scriptName: "lint" } }], edges: [] },
},
},
{
id: "spec",
kind: "optional-group",
column: "todo",
config: {
name: "Spec",
defaultOn: true,
template: { nodes: [{ id: "spec-step", kind: "prompt", config: { name: "Spec", prompt: "check" } }], edges: [] },
},
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "lint", condition: "success" },
{ from: "lint", to: "spec", condition: "success" },
{ from: "spec", to: "end", condition: "success" },
],
};
}
async function seedWorkflow(cwd: string, name = "QA workflow"): Promise<string> {
const store = new TaskStore(cwd);
await store.init();
try {
const workflow = await store.createWorkflowDefinition({ name, ir: linearWorkflowIr(name) });
return workflow.id;
} finally {
await store.close();
}
}
async function readTaskWorkflowState(cwd: string, taskId: string) {
const store = new TaskStore(cwd);
await store.init();
try {
const task = await store.getTask(taskId);
const selection = store.getTaskWorkflowSelection(taskId);
return { task, selection };
} finally {
await store.close();
}
}
async function removeDirWithRetries(path: string) {
/*
FNXC:CliTests 2026-06-19-11:23:
FN-6734 showed fixture removal can race SQLite/WAL close on loaded CLI workers; retry cleanup long enough for handles to drain instead of masking test bodies with larger timeouts or worker limits.
*/
const maxAttempts = 12;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await rm(path, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTEMPTY" && code !== "EBUSY") {
throw error;
}
if (attempt === maxAttempts) {
throw error;
}
await delay(50 * attempt);
}
}
}
async function enableResearch(cwd: string): Promise<TaskStore> {
const store = new TaskStore(cwd);
await store.init();
await store.updateGlobalSettings({
researchGlobalEnabled: true,
researchGlobalDefaults: { searchProvider: "searxng" },
researchGlobalSearxngUrl: "http://localhost:8888",
experimentalFeatures: { researchView: true } as Record<string, boolean>,
});
await store.updateSettings({
researchEnabled: true,
researchSettings: { enabled: true, searchProvider: "searxng" },
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
});
return store;
}
// ── Tests ──────────────────────────────────────────────────────────
describe("fn pi extension tool copy guardrails", () => {
it("describes fn_task_delete as soft delete and avoids irrecoverability claims (FN-5141)", () => {
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_task_delete") as
| { description?: string; promptGuidelines?: string[] }
| undefined;
expect(tool).toBeDefined();
expect(tool?.description ?? "").not.toMatch(/permanent|cannot be recovered|cannot be undone|deleted immediately/i);
expect(tool?.description ?? "").toMatch(/soft.?delete/i);
const guidelines = (tool?.promptGuidelines ?? []).join(" ");
expect(guidelines).toMatch(/soft.?delete/i);
expect(guidelines).not.toMatch(/permanent|cannot be recovered|cannot be undone|deleted immediately|irrecoverable/i);
});
it("describes fn_agent_stop as allowing error-state agents to be paused (FN-6018)", () => {
const api = createMockAPI();
kbExtension(api);
const tool = api.tools.get("fn_agent_stop") as
| { description?: string; promptGuidelines?: string[] }
| undefined;
expect(tool).toBeDefined();
const guidelines = (tool?.promptGuidelines ?? []).join(" ");
expect(guidelines).toMatch(/running, active, or in error/i);
expect(guidelines).toMatch(/idle.*already-paused/i);
expect(guidelines).not.toMatch(/idle, 'error', or already-paused/i);
});
});
// Audited in FN-3189: this exhaustive suite is expensive (~62s) and stale
// against modern extension behavior/tooling (see FN-3204). The maintained
// release lane lives in extension-integration.test.ts and uses
// FUSION_TEST_EXTENSION_INTEGRATION. Keep this under a separate legacy gate for
// historical debugging only.
const SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION =
process.env.FUSION_TEST_LEGACY_EXTENSION_INTEGRATION === "1" ||
process.env.FUSION_TEST_LEGACY_EXTENSION_INTEGRATION === "true";
describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (legacy exhaustive suite)", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(runGhJsonAsync).mockReset();
vi.mocked(runTaskPlan).mockReset();
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-test-"));
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await closeCachedStores();
await removeDirWithRetries(tmpDir);
});
describe("registration", () => {
it("registers all expected tools", () => {
const expected = [
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_trait_list",
"fn_workflow_select",
"fn_task_create",
"fn_task_update",
"fn_task_list",
"fn_task_show",
"fn_task_attach",
"fn_task_pause",
"fn_task_unpause",
"fn_task_retry",
"fn_task_bypass_review",
"fn_task_duplicate",
"fn_task_refine",
"fn_task_import_github",
"fn_task_import_github_issue",
"fn_task_browse_github_issues",
"fn_task_browse_gitlab_project_issues",
"fn_task_import_gitlab_project_issues",
"fn_task_browse_gitlab_group_issues",
"fn_task_import_gitlab_group_issues",
"fn_task_browse_gitlab_merge_requests",
"fn_task_import_gitlab_merge_requests",
"fn_task_archive",
"fn_task_unarchive",
"fn_task_delete",
"fn_task_plan",
"fn_research_run",
"fn_research_list",
"fn_research_get",
"fn_research_cancel",
"fn_insight_list",
"fn_insight_show",
"fn_insight_run_list",
"fn_insight_run_show",
"fn_mission_create",
"fn_mission_list",
"fn_mission_show",
"fn_mission_backfill_assertions",
"fn_mission_delete",
"fn_mission_update",
"fn_milestone_add",
"fn_slice_add",
"fn_feature_add",
"fn_feature_delete",
"fn_slice_delete",
"fn_milestone_delete",
"fn_slice_activate",
"fn_feature_link_task",
"fn_feature_update",
"fn_milestone_update",
"fn_agent_stop",
"fn_agent_start",
"fn_agent_create",
"fn_agent_delete",
"fn_list_agents",
"fn_delegate_task",
"fn_agent_show",
"fn_agent_org_chart",
"fn_skills_search",
"fn_skills_install",
] as const;
expect(Array.from(api.tools.keys()).sort()).toEqual([...expected].sort());
});
it("does not register engine-internal tools", () => {
expect(api.tools.has("fn_task_move")).toBe(false);
expect(api.tools.has("fn_task_update_step")).toBe(false);
expect(api.tools.has("fn_task_log")).toBe(false);
expect(api.tools.has("fn_task_merge")).toBe(false);
});
it("registers the /fn command", () => {
expect(api.commands.has("fn")).toBe(true);
expect(api.commands.get("fn")!.description).toContain("dashboard");
});
it("registers session_shutdown listener", () => {
expect(api.events.has("session_shutdown")).toBe(true);
});
});
describe("fn_task_plan", () => {
it("uses runTaskPlan return value for taskId regardless of prefix", async () => {
vi.mocked(runTaskPlan).mockResolvedValueOnce("PROJ-042");
const tool = api.tools.get("fn_task_plan")!;
const result = await tool.execute(
"plan-1",
{ description: "Plan a project task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(runTaskPlan).toHaveBeenCalledWith("Plan a project task", true, undefined, undefined);
expect(result.details.taskId).toBe("PROJ-042");
expect(result.content[0].text).toContain("Task PROJ-042");
});
});
describe("fn_task_create", () => {
it("creates a task and returns its ID", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Fix the login button" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toMatch(/^[A-Z]+-\d+$/);
expect(result.content[0].text).toContain(result.details.taskId);
expect(result.content[0].text).toContain("Fix the login button");
expect(result.content[0].text).toContain("triage");
expect(result.details.column).toBe("triage");
expect(result.details.priority).toBe("normal");
});
it("creates a task with explicit priority", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-priority",
{ description: "Urgent task", priority: "urgent" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.priority).toBe("urgent");
expect(result.content[0].text).toContain("Priority: urgent");
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s-priority", { id: result.details.taskId }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.priority).toBe("urgent");
});
it("creates a task with workflow_id selected and materialized", async () => {
const workflowId = await seedWorkflow(tmpDir, "Explicit create workflow");
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-workflow",
{ description: "Workflow task", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain(`(workflow: ${workflowId})`);
const { task, selection } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("creates a task without workflow_id using the project default workflow", async () => {
const workflowId = await seedWorkflow(tmpDir, "Default create workflow");
const store = new TaskStore(tmpDir);
await store.init();
try {
await store.setDefaultWorkflowId(workflowId);
} finally {
await store.close();
}
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-default-workflow",
{ description: "Default workflow task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const { task, selection } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("creates a task with dependencies", async () => {
const tool = api.tools.get("fn_task_create")!;
const first = await tool.execute(
"call-1",
{ description: "First task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await tool.execute(
"call-2",
{ description: "Second task", depends: [first.details.taskId] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toMatch(/^[A-Z]+-\d+$/);
expect(result.details.dependencies).toEqual([first.details.taskId]);
expect(result.content[0].text).toContain(`Dependencies: ${first.details.taskId}`);
});
it("creates a task with assigned agent ID", async () => {
const agentId = await seedAgent(tmpDir);
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Task with assignee", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toBe("FN-001");
expect(result.details.assignedAgentId).toBe(agentId);
expect(result.content[0].text).toContain(`Assigned to: ${agentId}`);
// Verify persistence via show
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.assignedAgentId).toBe(agentId);
});
it("rejects unknown agent IDs", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Task with bogus assignee", agentId: "agent-does-not-exist" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Agent agent-does-not-exist not found");
});
it("rejects ephemeral/runtime-managed agents", async () => {
const ephemeralId = await seedAgent(tmpDir, { ephemeral: true, name: "task-worker" });
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Task with worker assignee", agentId: ephemeralId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("ephemeral/runtime agent");
});
it("creates a task without assigned agent ID by default", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Task without assignee" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toMatch(/^[A-Z]+-\d+$/);
expect(result.details.assignedAgentId).toBeUndefined();
expect(result.content[0].text).not.toContain("Assigned to:");
});
it("FN-3799: treats empty-string agentId as unassigned on create", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Task without assignee", agentId: "" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.assignedAgentId).toBeUndefined();
expect(result.content[0].text).not.toContain("Agent not found");
expect(result.content[0].text).not.toContain("Assigned to:");
});
});
describe("fn_task_update", () => {
it("updates task title", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", title: "New Title" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.content[0].text).toContain("title");
expect(result.details.updatedFields).toEqual(["title"]);
// Verify via show
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.content[0].text).toContain("New Title");
});
it("updates task description", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original desc" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", description: "Updated description" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.details.updatedFields).toEqual(["description"]);
});
it("updates task dependencies", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "First" }, undefined, undefined, makeCtx(tmpDir));
await createTool.execute("c2", { description: "Second" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-002", depends: ["FN-001"] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-002");
expect(result.details.updatedFields).toEqual(["dependencies"]);
});
it("updates multiple fields at once", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", title: "New Title", description: "New desc", depends: [] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.updatedFields).toEqual(["title", "description", "dependencies"]);
});
it("updates task priority", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", priority: "urgent" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.details.updatedFields).toEqual(["priority"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.priority).toBe("urgent");
});
it("updates priority combined with other fields", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", title: "Retitled", priority: "high" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.updatedFields).toEqual(["title", "priority"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.title).toBe("Retitled");
expect(show.details.task.priority).toBe("high");
});
it("updates task workflow_id through workflow reconciliation", async () => {
const workflowId = await seedWorkflow(tmpDir, "Update workflow");
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const selectSpy = vi.spyOn(TaskStore.prototype, "selectTaskWorkflowAndReconcile");
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.updatedFields).toEqual(["workflowId"]);
expect(selectSpy).toHaveBeenCalledWith("FN-001", workflowId);
selectSpy.mockRestore();
const { task, selection } = await readTaskWorkflowState(tmpDir, "FN-001");
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("clears task workflow_id with null", async () => {
const workflowId = await seedWorkflow(tmpDir, "Clear workflow");
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original", workflow_id: workflowId }, undefined, undefined, makeCtx(tmpDir));
const clearSpy = vi.spyOn(TaskStore.prototype, "clearTaskWorkflowSelection");
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", workflow_id: null },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.updatedFields).toEqual(["workflowId"]);
expect(clearSpy).toHaveBeenCalledWith("FN-001");
clearSpy.mockRestore();
const { task, selection } = await readTaskWorkflowState(tmpDir, "FN-001");
expect(selection).toBeUndefined();
expect(task.enabledWorkflowSteps ?? []).toEqual([]);
});
it("returns an error for unknown workflow_id updates", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", workflow_id: "WF-404" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("WF-404");
});
it("treats empty-string workflow_id as not provided", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", title: "Retitled", workflow_id: " " },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.updatedFields).toEqual(["title"]);
const { selection } = await readTaskWorkflowState(tmpDir, "FN-001");
expect(selection).toBeUndefined();
});
it("rejects invalid priority value", () => {
const updateTool = api.tools.get("fn_task_update") as any;
const prioritySchema = updateTool.parameters.properties.priority;
const literalValues = prioritySchema.anyOf.map((entry: { const: string }) => entry.const);
expect(literalValues).toEqual(["low", "normal", "high", "urgent"]);
expect(literalValues).not.toContain("critical");
});
it("updates task assigned agent ID", async () => {
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const agentId = await seedAgent(tmpDir);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: created.details.taskId, agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain(`Updated ${created.details.taskId}`);
expect(result.content[0].text).toContain("agentId");
expect(result.details.updatedFields).toEqual(["agentId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: created.details.taskId }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.assignedAgentId).toBe(agentId);
});
it("FN-3799: clears task assigned agent ID with empty string", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", agentId: "" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.details.updatedFields).toEqual(["agentId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("clears task assigned agent ID with whitespace", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", agentId: " " },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.details.updatedFields).toEqual(["agentId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("clears task assigned agent ID with literal null string", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", agentId: "null" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.details.updatedFields).toEqual(["agentId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("clears node override with empty string", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const setNode = await updateTool.execute(
"u1",
{ id: "FN-001", nodeId: "node-123" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(setNode.isError).not.toBe(true);
const clearNode = await updateTool.execute(
"u2",
{ id: "FN-001", nodeId: "" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(clearNode.content[0].text).toContain("Updated FN-001");
expect(clearNode.details.updatedFields).toEqual(["nodeId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.nodeId).toBeNull();
});
it("rejects unknown agent IDs on update", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", agentId: "agent-does-not-exist" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe("Agent agent-does-not-exist not found");
});
it("clears task assigned agent ID with null", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-001", agentId: null },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated FN-001");
expect(result.content[0].text).toContain("agentId");
expect(result.details.updatedFields).toEqual(["agentId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("returns error when task not found", async () => {
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"u1",
{ id: "FN-999", title: "Nope" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("FN-999 not found");
});
});
describe("fn_task_list", () => {
it("returns empty message when no tasks", async () => {
const tool = api.tools.get("fn_task_list")!;
const result = await tool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toBe("No tasks yet.");
expect(result.details.count).toBe(0);
});
it("lists tasks grouped by column", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "Task A" },
undefined,
undefined,
makeCtx(tmpDir),
);
await createTool.execute(
"c2",
{ description: "Task B" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Planning (2)");
expect(result.content[0].text).toContain("FN-001");
expect(result.content[0].text).toContain("FN-002");
expect(result.details.count).toBe(2);
});
it("includes concise provenance in list rows", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.createTask({
description: "Created by dashboard",
source: { sourceType: "dashboard_ui" },
});
await store.createTask({
description: "Created by agent",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-123",
sourceMetadata: { agentName: "Reviewer Bot" },
},
});
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute("call-2", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("FN-001 Created by dashboard [via: Dashboard]");
expect(result.content[0].text).toContain("FN-002 Created by agent [via: Agent (Reviewer Bot)]");
});
it("filters by column", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "Task A" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("fn_task_list")!;
const triageResult = await listTool.execute(
"call-1",
{ column: "triage" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(triageResult.content[0].text).toContain("Planning (1)");
expect(triageResult.content[0].text).toContain("FN-001");
const todoResult = await listTool.execute(
"call-2",
{ column: "todo" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(todoResult.content[0].text).toBe("");
});
it("respects per-column limit", async () => {
const createTool = api.tools.get("fn_task_create")!;
for (let i = 0; i < 5; i++) {
await createTool.execute(
`c${i}`,
{ description: `Task ${i}` },
undefined,
undefined,
makeCtx(tmpDir),
);
}
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"call-1",
{ limit: 2 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Planning (5)");
expect(result.content[0].text).toContain("FN-001");
expect(result.content[0].text).toContain("FN-002");
expect(result.content[0].text).not.toContain("FN-003");
expect(result.content[0].text).toContain("... and 3 more");
});
});
describe("fn_task_show", () => {
it("shows task details", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute("c1", { description: "Implement caching layer" }, undefined, undefined, makeCtx(tmpDir));
const showTool = api.tools.get("fn_task_show")!;
const result = await showTool.execute("call-1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("FN-001");
expect(result.content[0].text).toContain("Implement caching layer");
expect(result.content[0].text).toContain("Planning");
expect(result.content[0].text).toContain("Created via: API");
expect(result.details.task).toBeDefined();
expect(result.details.task.id).toBe("FN-001");
});
it("shows agent and dashboard provenance", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.createTask({
description: "Agent created",
source: {
sourceType: "agent_heartbeat",
sourceAgentId: "agent-999",
sourceMetadata: { agentName: "Scout" },
},
});
await store.createTask({ description: "UI created", source: { sourceType: "dashboard_ui" } });
const showTool = api.tools.get("fn_task_show")!;
const agentResult = await showTool.execute("call-2", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
const dashboardResult = await showTool.execute("call-3", { id: "FN-002" }, undefined, undefined, makeCtx(tmpDir));
expect(agentResult.content[0].text).toContain("Created via: Agent (Scout)");
expect(dashboardResult.content[0].text).toContain("Created via: Dashboard");
});
it("shows duplicate lineage with archived annotation", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const archivedSource = await store.createTask({ description: "Archived source" });
await store.moveTask(archivedSource.id, "done");
await store.archiveTask(archivedSource.id);
await store.createTask({
description: "Dup task",
source: { sourceType: "chat_session", sourceMetadata: { duplicateOfTaskIds: [archivedSource.id, "FN-404"] } },
});
const showTool = api.tools.get("fn_task_show")!;
const result = await showTool.execute("call-4", { id: "FN-002" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain(`Duplicate of: ${archivedSource.id} (archived), FN-404`);
});
});
describe("fn_task_attach", () => {
it("attaches a file to a task", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const testFile = join(tmpDir, "test.txt");
await writeFile(testFile, "hello world");
const attachTool = api.tools.get("fn_task_attach")!;
const result = await attachTool.execute(
"call-1",
{ id: "FN-001", path: "test.txt" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Attached to FN-001");
expect(result.content[0].text).toContain("test.txt");
expect(result.details.attachment).toBeDefined();
expect(result.details.attachment.originalName).toBe("test.txt");
});
it("attaches a file from an in-boundary nested subdirectory", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
await mkdir(join(tmpDir, "nested", "dir"), { recursive: true });
const testFile = join(tmpDir, "nested", "dir", "inner.txt");
await writeFile(testFile, "inner content");
const attachTool = api.tools.get("fn_task_attach")!;
const result = await attachTool.execute(
"call-1",
{ id: "FN-001", path: "nested/dir/inner.txt" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Attached to FN-001");
expect(result.details.attachment.originalName).toBe("inner.txt");
});
/*
* FNXC:CliTaskAttach 2026-07-05-00:00:
* Regression coverage for FN-7619 — fn_task_attach must reject any path
* (relative traversal, absolute, or @-prefixed traversal) that resolves
* outside the task worktree boundary (ctx.cwd), and must never create an
* attachment when it does.
*/
describe("worktree boundary guard (FN-7619)", () => {
let outsideDir: string;
let outsideFile: string;
beforeEach(async () => {
outsideDir = await mkdtemp(join(tmpdir(), "kb-ext-test-outside-"));
outsideFile = join(outsideDir, "secret.txt");
await writeFile(outsideFile, "top secret contents");
});
afterEach(async () => {
await rm(outsideDir, { recursive: true, force: true });
});
it("rejects a relative traversal path escaping the worktree", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const relPath = join(relative(tmpDir, outsideDir), "secret.txt");
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: relPath },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow(/boundary|outside/i);
const store = new TaskStore(tmpDir);
const task = await store.getTask("FN-001");
expect(task?.attachments ?? []).toHaveLength(0);
});
it("rejects an absolute path outside the worktree", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: outsideFile },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow(/boundary|outside/i);
const store = new TaskStore(tmpDir);
const task = await store.getTask("FN-001");
expect(task?.attachments ?? []).toHaveLength(0);
});
it("rejects an @-prefixed traversal path escaping the worktree", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const relPath = join(relative(tmpDir, outsideDir), "secret.txt");
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: `@${relPath}` },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow(/boundary|outside/i);
const store = new TaskStore(tmpDir);
const task = await store.getTask("FN-001");
expect(task?.attachments ?? []).toHaveLength(0);
});
});
it("rejects unsupported file types", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const testFile = join(tmpDir, "file.exe");
await writeFile(testFile, "binary");
const attachTool = api.tools.get("fn_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "FN-001", path: "file.exe" },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow("Unsupported file type");
});
});
describe("fn_task_pause / unpause", () => {
it("FN-7188 describes pause as user-requested manual control, not failure handling", () => {
const pauseTool = api.tools.get("fn_task_pause")!;
expect(pauseTool.description).toContain("explicit user-requested manual control");
expect(pauseTool.description).toContain("should not pause tasks to handle failures or blockers");
expect(pauseTool.description).toContain("let the task surface as failed");
});
it("pauses and unpauses a task", async () => {
const createTool = api.tools.get("fn_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const pauseTool = api.tools.get("fn_task_pause")!;
const pauseResult = await pauseTool.execute(
"call-1",
{ id: "FN-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(pauseResult.content[0].text).toContain("Paused FN-001");
// Verify it's paused
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute(
"call-2",
{ id: "FN-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.content[0].text).toContain("PAUSED");
// Unpause
const unpauseTool = api.tools.get("fn_task_unpause")!;
const unpauseResult = await unpauseTool.execute(
"call-3",
{ id: "FN-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(unpauseResult.content[0].text).toContain("Unpaused FN-001");
});
});
describe("fn_mission_create", () => {
it("creates mission and returns mission data", async () => {
const tool = api.tools.get("fn_mission_create")!;
const result = await tool.execute(
"call-1",
{ title: "Test Mission", description: "Test description", autoAdvance: true, baseBranch: "develop" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.missionId).toBeDefined();
expect(result.details.title).toBe("Test Mission");
expect(result.details.autoAdvance).toBe(true);
expect(result.content[0].text).toContain("Created");
expect(result.content[0].text).toContain("Test Mission");
expect(result.content[0].text).toContain("Auto-advance: enabled");
const store = new TaskStore(tmpDir);
await store.init();
const mission = store.getMissionStore().getMission(result.details.missionId);
expect(mission?.baseBranch).toBe("develop");
});
});
describe("fn_mission_list", () => {
it("returns formatted list of missions", async () => {
const createTool = api.tools.get("fn_mission_create")!;
await createTool.execute(
"c1",
{ title: "Mission A" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.count).toBeGreaterThanOrEqual(1);
expect(result.content[0].text).toContain("Missions");
expect(result.content[0].text).toContain("Summary:");
});
it("includes mission interview drafts by default and exposes them in details", async () => {
const store = new TaskStore(tmpDir);
await store.init();
store.getDatabase().prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'awaiting_input', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-1", "Draft Mission", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
store.getDatabase().prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'complete', ?, '{}', '[]', NULL, '{}', '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-2", "Ready Mission", "2026-05-12T00:01:00.000Z", "2026-05-12T00:01:00.000Z");
const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute("call-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("Drafts (2)");
expect(result.content[0].text).toContain("draft-1: Draft Mission (draft · interview awaiting_input)");
expect(result.content[0].text).toContain("draft-2: Ready Mission (draft · interview plan ready)");
expect(result.details.drafts).toEqual([
{
id: "draft-2",
title: "Ready Mission",
status: "complete",
updatedAt: "2026-05-12T00:01:00.000Z",
},
{
id: "draft-1",
title: "Draft Mission",
status: "awaiting_input",
updatedAt: "2026-05-12T00:00:00.000Z",
},
]);
});
it("suppresses mission interview drafts when includeDrafts is false", async () => {
const store = new TaskStore(tmpDir);
await store.init();
store.getDatabase().prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, 'mission_interview', 'error', ?, '{}', '[]', NULL, NULL, '', NULL, NULL, ?, ?, NULL, NULL)`,
).run("draft-2", "Hidden Draft", "2026-05-12T00:00:00.000Z", "2026-05-12T00:00:00.000Z");
const listTool = api.tools.get("fn_mission_list")!;
const result = await listTool.execute("call-1", { includeDrafts: false }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).not.toContain("Drafts");
expect(result.details.drafts).toEqual([]);
});
});
describe("fn_mission_show", () => {
it("returns mission with hierarchy and linked goals", async () => {
const createTool = api.tools.get("fn_mission_create")!;
const goalTool = api.tools.get("fn_goal_create")!;
const linkTool = api.tools.get("fn_mission_link_goal")!;
const created = await createTool.execute(
"c1",
{ title: "Test Mission" },
undefined,
undefined,
makeCtx(tmpDir),
);
const goal = await goalTool.execute(
"g1",
{ title: "Connect mission work to goals" },
undefined,
undefined,
makeCtx(tmpDir),
);
await linkTool.execute(
"link-1",
{ missionId: created.details.missionId, goalId: goal.details.goalId },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("fn_mission_show")!;
const result = await showTool.execute(
"call-1",
{ id: created.details.missionId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.mission).toBeDefined();
expect(result.content[0].text).toContain("Test Mission");
expect(result.content[0].text).toContain("Linked Goals:");
expect(result.content[0].text).toContain(`- ${goal.details.goalId}: Connect mission work to goals`);
expect(result.details.mission.linkedGoals).toEqual([
expect.objectContaining({ id: goal.details.goalId, title: "Connect mission work to goals" }),
]);
});
it("renders acceptanceCriteria / verification for milestones, slices, and features", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const featureTool = api.tools.get("fn_feature_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const store = new TaskStore(tmpDir);
await store.init();
const missionStore = store.getMissionStore();
const milestone = missionStore.addMilestone(mission.details.missionId, {
title: "Milestone",
acceptanceCriteria: "MILESTONE_AC_MARKER",
});
const slice = missionStore.addSlice(milestone.id, {
title: "Slice",
verification: "SLICE_VERIFY_MARKER",
});
await featureTool.execute(
"f1",
{
sliceId: slice.id,
title: "Feature",
acceptanceCriteria: "FEATURE_AC_MARKER",
},
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("fn_mission_show")!;
const result = await showTool.execute("call-1", { id: mission.details.missionId }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain(" AC: MILESTONE_AC_MARKER");
expect(result.content[0].text).toContain(" Verification: SLICE_VERIFY_MARKER");
expect(result.content[0].text).toContain(" AC: FEATURE_AC_MARKER");
});
it("truncates long acceptanceCriteria text while keeping full details.mission values", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const longValue = "LONG_AC_".repeat(40);
const store = new TaskStore(tmpDir);
await store.init();
const missionStore = store.getMissionStore();
missionStore.addMilestone(mission.details.missionId, {
title: "Milestone",
acceptanceCriteria: longValue,
});
const showTool = api.tools.get("fn_mission_show")!;
const result = await showTool.execute("call-1", { id: mission.details.missionId }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("… (truncated,");
expect(result.details.mission.milestones[0].acceptanceCriteria).toBe(longValue);
});
it("renders an empty linked goals state when no goals are linked", async () => {
const createTool = api.tools.get("fn_mission_create")!;
const created = await createTool.execute(
"c1",
{ title: "Mission Without Goals" },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("fn_mission_show")!;
const result = await showTool.execute(
"call-1",
{ id: created.details.missionId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Linked Goals:");
expect(result.content[0].text).toContain("No linked goals.");
expect(result.details.mission.linkedGoals).toEqual([]);
});
it("returns error when mission not found", async () => {
const showTool = api.tools.get("fn_mission_show")!;
const result = await showTool.execute(
"call-1",
{ id: "M-999" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
});
describe("fn_mission_backfill_assertions", () => {
it("supports dry-run and apply execution", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const backfillTool = api.tools.get("fn_mission_backfill_assertions")!;
const mission = await missionTool.execute("m1", { title: "Backfill Mission" }, undefined, undefined, makeCtx(tmpDir));
const store = new TaskStore(tmpDir);
await store.init();
const missionStore = store.getMissionStore();
const milestone = missionStore.addMilestone(mission.details.missionId, { title: "Milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice" });
const feature = missionStore.addFeature(slice.id, {
title: "Feature needing assertion",
acceptanceCriteria: "must be true",
});
expect(missionStore.listAssertionsForFeature(feature.id)).toHaveLength(0);
const dryRun = await backfillTool.execute(
"bf-1",
{ missionId: mission.details.missionId, dryRun: true },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(dryRun.details.repaired.length).toBe(1);
expect(missionStore.listAssertionsForFeature(feature.id)).toHaveLength(0);
const apply = await backfillTool.execute(
"bf-2",
{ missionId: mission.details.missionId, dryRun: false },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(apply.details.repaired.length).toBe(1);
expect(missionStore.listAssertionsForFeature(feature.id)).toHaveLength(1);
expect(apply.content[0].text).toContain("Backfill apply complete");
});
});
describe("fn_mission_delete", () => {
it("deletes mission and confirms", async () => {
// Create mission
const createTool = api.tools.get("fn_mission_create")!;
const created = await createTool.execute(
"c1",
{ title: "Mission to Delete" },
undefined,
undefined,
makeCtx(tmpDir),
);
const deleteTool = api.tools.get("fn_mission_delete")!;
const result = await deleteTool.execute(
"call-1",
{ id: created.details.missionId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.missionId).toBe(created.details.missionId);
expect(result.content[0].text).toContain("Deleted");
});
});
describe("fn_milestone_add", () => {
it("creates a milestone in the mission store", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const result = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone", description: "Phase 1" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getMilestone(result.details.milestoneId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Milestone");
expect(persisted?.description).toBe("Phase 1");
});
});
describe("fn_slice_add", () => {
it("creates a slice in the mission store", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice", description: "Work unit" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getSlice(result.details.sliceId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Slice");
expect(persisted?.description).toBe("Work unit");
});
});
describe("fn_feature_add", () => {
it("creates a feature in the mission store", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Deliverable", acceptanceCriteria: "Must pass" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(result.details.featureId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Feature");
expect(persisted?.acceptanceCriteria).toBe("Must pass");
});
});
describe("fn_feature_delete", () => {
it("deletes feature, guards linked task, and handles missing feature", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const createTaskTool = api.tools.get("fn_task_create")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const deleteTool = api.tools.get("fn_feature_delete")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute("ms1", { missionId: mission.details.missionId, title: "Milestone" }, undefined, undefined, makeCtx(tmpDir));
const slice = await sliceTool.execute("sl1", { milestoneId: milestone.details.milestoneId, title: "Slice" }, undefined, undefined, makeCtx(tmpDir));
const feature = await featureTool.execute("f1", { sliceId: slice.details.sliceId, title: "Feature" }, undefined, undefined, makeCtx(tmpDir));
const task = await createTaskTool.execute("t1", { description: "Task for feature" }, undefined, undefined, makeCtx(tmpDir));
await linkTool.execute("l1", { featureId: feature.details.featureId, taskId: task.details.taskId }, undefined, undefined, makeCtx(tmpDir));
const guarded = await deleteTool.execute("d1", { featureId: feature.details.featureId }, undefined, undefined, makeCtx(tmpDir));
expect(guarded.isError).toBe(true);
expect(guarded.content[0].text).toContain("linked to task");
const forced = await deleteTool.execute("d2", { featureId: feature.details.featureId, force: true }, undefined, undefined, makeCtx(tmpDir));
expect(forced.isError).not.toBe(true);
expect(forced.content[0].text).toContain("Deleted");
const missing = await deleteTool.execute("d3", { featureId: feature.details.featureId }, undefined, undefined, makeCtx(tmpDir));
expect(missing.isError).toBe(true);
expect(missing.content[0].text).toContain("not found");
});
});
describe("fn_slice_activate", () => {
it("returns error when slice is already active", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const activateTool = api.tools.get("fn_slice_activate")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
await activateTool.execute("sl2", { id: slice.details.sliceId }, undefined, undefined, makeCtx(tmpDir));
const result = await activateTool.execute("sl3", { id: slice.details.sliceId }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not pending");
});
it("activates slice and updates status", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const activateTool = api.tools.get("fn_slice_activate")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await activateTool.execute(
"sl2",
{ id: slice.details.sliceId },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getSlice(slice.details.sliceId);
expect(result.content[0].text).toContain("Activated");
expect(result.details.status).toBe("active");
expect(persisted?.status).toBe("active");
});
});
describe("fn_feature_link_task", () => {
it("returns error when task is missing", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await linkTool.execute(
"l0",
{ featureId: feature.details.featureId, taskId: "FN-999" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task FN-999 not found");
});
it("returns clear error when task is archived/non-active", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const store = new TaskStore(tmpDir);
await store.init();
const archivedTask = await store.createTask({ description: "Archived task" });
await store.moveTask(archivedTask.id, "done");
await store.archiveTask(archivedTask.id);
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await linkTool.execute(
"l0b",
{ featureId: feature.details.featureId, taskId: archivedTask.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("task is not on the active board");
expect(result.content[0].text).toContain(`Cannot link feature ${feature.details.featureId} to task ${archivedTask.id}`);
expect(result.details.error).toContain("Only active tasks can be linked to features");
});
it("links feature to task", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const createTaskTool = api.tools.get("fn_task_create")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const taskResult = await createTaskTool.execute(
"t1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await linkTool.execute(
"l1",
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const missionStore = store.getMissionStore();
const persisted = missionStore.getFeature(feature.details.featureId);
const linkedTask = await store.getTask(taskResult.details.taskId);
expect(result.content[0].text).toContain(taskResult.details.taskId);
expect(result.details.taskId).toBe(taskResult.details.taskId);
expect(persisted?.status).toBe("triaged");
expect(linkedTask.sliceId).toBe(slice.details.sliceId);
});
});
describe("fn_feature_update", () => {
it("patches title, description, and acceptanceCriteria", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"fu1",
{
id: feature.details.featureId,
title: "Updated Feature",
description: "Updated description",
acceptanceCriteria: "AC new",
},
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(result.content[0].text).toContain("Updated");
expect(persisted?.title).toBe("Updated Feature");
expect(persisted?.description).toBe("Updated description");
expect(persisted?.acceptanceCriteria).toBe("AC new");
});
it("partial patch preserves untouched fields", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu2",
{ id: feature.details.featureId, acceptanceCriteria: "AC patched" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(persisted?.title).toBe("Feature");
expect(persisted?.description).toBe("Original");
expect(persisted?.acceptanceCriteria).toBe("AC patched");
});
it("preserves slice ordering", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const first = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "F1" },
undefined,
undefined,
makeCtx(tmpDir),
);
const second = await featureTool.execute(
"f2",
{ sliceId: slice.details.sliceId, title: "F2" },
undefined,
undefined,
makeCtx(tmpDir),
);
const third = await featureTool.execute(
"f3",
{ sliceId: slice.details.sliceId, title: "F3" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu3",
{ id: second.details.featureId, title: "F2 Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const features = store.getMissionStore().listFeatures(slice.details.sliceId);
expect(features.map((featureItem) => featureItem.id)).toEqual([
first.details.featureId,
second.details.featureId,
third.details.featureId,
]);
});
it("preserves linked task association", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const createTaskTool = api.tools.get("fn_task_create")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const taskResult = await createTaskTool.execute(
"t1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
await linkTool.execute(
"l1",
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu4",
{ id: feature.details.featureId, title: "Updated Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(persisted?.taskId).toBe(taskResult.details.taskId);
expect(persisted?.status).toBe("triaged");
});
it("returns error when feature not found", async () => {
const updateTool = api.tools.get("fn_feature_update")!;
const result = await updateTool.execute(
"fu5",
{ id: "F-999", title: "Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Feature F-999 not found");
});
it("returns error when no fields supplied", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"fu6",
{ id: feature.details.featureId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("No fields to update");
});
});
describe("fn_mission_update", () => {
it("updates mission description and persists", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const updateTool = api.tools.get("fn_mission_update")!;
const mission = await missionTool.execute(
"m1",
{ title: "Mission", description: "Original description" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"mu1",
{ id: mission.details.missionId, description: " Updated description " },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated");
expect(result.details.missionId).toBe(mission.details.missionId);
expect(result.details.description).toBe("Updated description");
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getMission(mission.details.missionId);
expect(persisted?.description).toBe("Updated description");
});
it("returns error when mission not found", async () => {
const updateTool = api.tools.get("fn_mission_update")!;
const result = await updateTool.execute(
"mu2",
{ id: "M-999", title: "Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Mission M-999 not found");
});
it("returns error when no fields supplied", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const updateTool = api.tools.get("fn_mission_update")!;
const mission = await missionTool.execute(
"m1",
{ title: "Mission", description: "Original description" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"mu3",
{ id: mission.details.missionId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("No fields to update");
});
});
describe("fn_milestone_update", () => {
it("patches title, description, and acceptanceCriteria", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const updateTool = api.tools.get("fn_milestone_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone", description: "Original" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"mu1",
{ id: milestone.details.milestoneId, title: "Updated Milestone", description: "Updated description", acceptanceCriteria: "AC new" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Updated");
expect(result.details.title).toBe("Updated Milestone");
expect(result.details.description).toBe("Updated description");
expect(result.details.acceptanceCriteria).toBe("AC new");
});
it("partial patch updates only acceptanceCriteria", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const updateTool = api.tools.get("fn_milestone_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone", description: "Original" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"mu2",
{ id: milestone.details.milestoneId, acceptanceCriteria: "Only AC" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getMilestone(milestone.details.milestoneId);
expect(persisted?.title).toBe("Milestone");
expect(persisted?.description).toBe("Original");
expect(persisted?.acceptanceCriteria).toBe("Only AC");
});
it("returns error when milestone not found", async () => {
const updateTool = api.tools.get("fn_milestone_update")!;
const result = await updateTool.execute(
"mu3",
{ id: "MS-999", title: "Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Milestone MS-999 not found");
});
it("returns error when no fields supplied", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const updateTool = api.tools.get("fn_milestone_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"mu4",
{ id: milestone.details.milestoneId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("No fields to update");
});
it("trims incoming field values", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const updateTool = api.tools.get("fn_milestone_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"mu5",
{ id: milestone.details.milestoneId, title: " Trimmed ", description: " Desc ", acceptanceCriteria: " AC " },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getMilestone(milestone.details.milestoneId);
expect(persisted?.title).toBe("Trimmed");
expect(persisted?.description).toBe("Desc");
expect(persisted?.acceptanceCriteria).toBe("AC");
});
});
describe("GitHub import tools", () => {
it("fn_task_import_github requires gh auth", async () => {
const tool = api.tools.get("fn_task_import_github")!;
vi.mocked(isGhAvailable).mockReturnValue(false);
await expect(
tool.execute("gh-1", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir)),
).rejects.toThrow("GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
});
it("fn_task_import_github imports issues via gh api", async () => {
const tool = api.tools.get("fn_task_import_github")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
{
number: 1,
title: "Issue one",
body: "First issue body",
html_url: "https://github.com/acme/demo/issues/1",
},
{
number: 2,
title: "Issue two",
body: "Second issue body",
html_url: "https://github.com/acme/demo/issues/2",
},
] as never);
const result = await tool.execute(
"gh-2",
{ ownerRepo: "acme/demo", limit: 5 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Imported 2 tasks from acme/demo");
expect(result.details.createdTasks).toHaveLength(2);
expect(vi.mocked(runGhJsonAsync)).toHaveBeenCalledWith(
["api", "repos/acme/demo/issues?state=open&per_page=5"],
{ signal: undefined },
);
const store = new TaskStore(tmpDir);
await store.init();
const tasks = await store.listTasks({ includeArchived: true });
expect(tasks).toHaveLength(2);
const issueOneTask = tasks.find((task) => task.sourceIssue?.issueNumber === 1);
expect(issueOneTask?.githubTracking?.enabled).toBeUndefined();
expect(issueOneTask?.sourceIssue).toEqual({
provider: "github",
repository: "acme/demo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/acme/demo/issues/1",
});
expect(issueOneTask?.source?.sourceMetadata).toEqual({
issueUrl: "https://github.com/acme/demo/issues/1",
issueNumber: 1,
});
await store.close();
});
it("fn_task_import_github marks imported issues as tracked when tracking defaults are on", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ githubTrackingEnabledByDefault: true });
await store.close();
const tool = api.tools.get("fn_task_import_github")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
{
number: 7,
title: "Tracked issue",
body: "Tracked issue body",
html_url: "https://github.com/acme/demo/issues/7",
},
] as never);
await tool.execute("gh-tracked-bulk", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir));
const verifyStore = new TaskStore(tmpDir);
await verifyStore.init();
const tasks = await verifyStore.listTasks({ includeArchived: true });
const imported = tasks.find((task) => task.sourceIssue?.issueNumber === 7);
expect(imported?.githubTracking?.enabled).toBe(true);
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
provider: "github",
repository: "acme/demo",
issueNumber: 7,
}));
await verifyStore.close();
});
it("fn_task_import_github marks imported issues as tracked when import linking is on and new-task defaults are off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
githubTrackingEnabledByDefault: false,
githubLinkImportedIssuesToTracking: true,
});
await store.close();
const tool = api.tools.get("fn_task_import_github")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
{
number: 9,
title: "Import-linked issue",
body: null,
html_url: "https://github.com/acme/demo/issues/9",
},
] as never);
await tool.execute("gh-import-linked-bulk", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir));
const verifyStore = new TaskStore(tmpDir);
await verifyStore.init();
const tasks = await verifyStore.listTasks({ includeArchived: true });
const imported = tasks.find((task) => task.sourceIssue?.issueNumber === 9);
expect(imported?.description).toContain("(no description)");
expect(imported?.githubTracking?.enabled).toBe(true);
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
provider: "github",
repository: "acme/demo",
issueNumber: 9,
}));
await verifyStore.close();
});
it("fn_task_import_github_issue leaves imported issues unforced when tracking defaults are off", async () => {
const tool = api.tools.get("fn_task_import_github_issue")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
number: 6,
title: "Single untracked issue",
body: "Single issue body",
html_url: "https://github.com/acme/demo/issues/6",
} as never);
const result = await tool.execute(
"gh-untracked-single",
{ owner: "acme", repo: "demo", issueNumber: 6 },
undefined,
undefined,
makeCtx(tmpDir),
);
const verifyStore = new TaskStore(tmpDir);
await verifyStore.init();
const imported = await verifyStore.getTask(result.details.taskId);
expect(imported?.githubTracking?.enabled).toBeUndefined();
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
provider: "github",
repository: "acme/demo",
issueNumber: 6,
}));
await verifyStore.close();
});
it("fn_task_import_github_issue marks imported issues as tracked when tracking defaults are on", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ githubTrackingEnabledByDefault: true });
await store.close();
const tool = api.tools.get("fn_task_import_github_issue")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
number: 8,
title: "Single tracked issue",
body: "Single issue body",
html_url: "https://github.com/acme/demo/issues/8",
} as never);
const result = await tool.execute(
"gh-tracked-single",
{ owner: "acme", repo: "demo", issueNumber: 8 },
undefined,
undefined,
makeCtx(tmpDir),
);
const verifyStore = new TaskStore(tmpDir);
await verifyStore.init();
const imported = await verifyStore.getTask(result.details.taskId);
expect(imported?.githubTracking?.enabled).toBe(true);
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
provider: "github",
repository: "acme/demo",
issueNumber: 8,
}));
await verifyStore.close();
});
it("fn_task_import_github_issue marks imported issues as tracked when import linking is on and new-task defaults are off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({
githubTrackingEnabledByDefault: false,
githubLinkImportedIssuesToTracking: true,
});
await store.close();
const tool = api.tools.get("fn_task_import_github_issue")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
number: 10,
title: "Single import-linked issue",
body: null,
html_url: "https://github.com/acme/demo/issues/10",
} as never);
const result = await tool.execute(
"gh-import-linked-single",
{ owner: "acme", repo: "demo", issueNumber: 10 },
undefined,
undefined,
makeCtx(tmpDir),
);
const verifyStore = new TaskStore(tmpDir);
await verifyStore.init();
const imported = await verifyStore.getTask(result.details.taskId);
expect(imported?.description).toContain("(no description)");
expect(imported?.githubTracking?.enabled).toBe(true);
expect(imported?.sourceIssue).toEqual(expect.objectContaining({
provider: "github",
repository: "acme/demo",
issueNumber: 10,
}));
await verifyStore.close();
});
it("fn_task_import_github skips issues already imported via sourceIssue even when description was edited", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.createTask({
title: "Existing imported issue",
description: "Edited description without source URL",
sourceIssue: {
provider: "github",
repository: "acme/demo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/acme/demo/issues/1",
},
});
await store.close();
const tool = api.tools.get("fn_task_import_github")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
{
number: 1,
title: "Issue one",
body: "First issue body",
html_url: "https://github.com/acme/demo/issues/1",
},
] as never);
const result = await tool.execute("gh-2b", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("Imported 0 tasks from acme/demo");
expect(result.details.createdTasks).toHaveLength(0);
});
it("fn_task_import_github_issue skips issues already imported via sourceIssue even when description was edited", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const existing = await store.createTask({
title: "Existing imported issue",
description: "Edited description without source URL",
sourceIssue: {
provider: "github",
repository: "acme/demo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/acme/demo/issues/1",
},
});
await store.close();
const tool = api.tools.get("fn_task_import_github_issue")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
number: 1,
title: "Issue one",
body: "First issue body",
html_url: "https://github.com/acme/demo/issues/1",
} as never);
const result = await tool.execute(
"gh-2c",
{ owner: "acme", repo: "demo", issueNumber: 1 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details).toMatchObject({ skipped: true, existingTaskId: existing.id });
expect(result.content[0].text).toContain(existing.id);
});
it("fn_task_browse_github_issues lists issues via gh api", async () => {
const tool = api.tools.get("fn_task_browse_github_issues")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
{
number: 10,
title: "Investigate latency",
body: null,
html_url: "https://github.com/acme/demo/issues/10",
labels: [{ name: "perf" }],
},
] as never);
const result = await tool.execute(
"gh-3",
{ owner: "acme", repo: "demo", limit: 10 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Found 1 open issues in acme/demo");
expect(result.details.issues[0]).toMatchObject({ number: 10, labels: ["perf"] });
expect(vi.mocked(runGhJsonAsync)).toHaveBeenCalledWith(
["api", "repos/acme/demo/issues?state=open&per_page=10"],
{ signal: undefined },
);
});
});
});
describe("fn pi extension (runnable structured-output regression slice)", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
let openStores: TaskStore[] = [];
function createStore(): TaskStore {
const store = new TaskStore(tmpDir);
openStores.push(store);
return store;
}
beforeEach(async () => {
vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(runGhJsonAsync).mockReset();
vi.mocked(runTaskPlan).mockReset();
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-fast-"));
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
/*
FNXC:CliTests 2026-06-19-11:02:
FN-6734 reproduced CLI package-lane ENOTEMPTY and 5s timeouts when the extension store cache kept real TaskStore handles open while the fixture root was removed.
Close the cache before temp-root cleanup so the high-value regression slice stays in the default 5s lane without timeout or worker appeasement.
*/
for (const store of openStores.splice(0)) {
try {
await store.close();
} catch {
// Best effort: close all real stores before removing fixture roots.
}
}
await closeCachedStores();
await removeDirWithRetries(tmpDir);
});
it("closes cached TaskStore handles before fixture removal (FN-6734/FN-6839 regression)", async () => {
/* FNXC:CliTests 2026-06-21-09:58: FN-6839 extends FN-6734 from "close was called" to "close was awaited" because TaskStore.close() quiesces deferred task-created writes before SQLite/WAL handles are safe to remove under loaded CLI lanes. */
const originalClose = TaskStore.prototype.close; let closeSettled = false;
const closeSpy = vi.spyOn(TaskStore.prototype, "close").mockImplementation(async function (this: TaskStore) {
await new Promise<void>((resolve) => setImmediate(resolve));
await originalClose.call(this);
closeSettled = true;
});
await api.tools.get("fn_task_create")!.execute("close-before-remove", { description: "seed cached store" }, undefined, undefined, makeCtx(tmpDir));
await closeCachedStores();
expect(closeSpy).toHaveBeenCalled();
expect(closeSettled).toBe(true);
await expect(rm(tmpDir, { recursive: true, force: true })).resolves.not.toThrow();
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-fast-"));
});
it("returns machine-consumable task metadata without assuming FN-* prefixes", async () => {
const createTool = api.tools.get("fn_task_create")!;
const parent = await createTool.execute("create-1", { description: "parent" }, undefined, undefined, makeCtx(tmpDir));
const result = await createTool.execute(
"create-2",
{ description: "child", depends: [parent.details.taskId] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toMatch(/^[A-Z]+-\d+$/);
expect(result.details.dependencies).toEqual([parent.details.taskId]);
expect(result.content[0].text).toContain(result.details.taskId);
});
describe("fn_task_list", () => {
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;
function expectSingleBoundedTextBlock(result: any) {
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
expect(result.content[0].text).toBeTruthy();
expect(result.content[0].text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(result.content[0].text.length).toBeLessThanOrEqual(HOST_SAFE_TASK_LIST_TEXT_CEILING);
}
function realisticTaskTitle(column: string, index: number) {
return `${column} realistic task ${String(index).padStart(3, "0")} keeps enough descriptive context for text agents without artificial padding`;
}
it("returns bounded text for omitted and provided column/limit params", async () => {
const store = createStore();
await store.init();
try {
await store.createTask({ description: "Planning task one" });
await store.createTask({ description: "Todo task one", column: "todo" });
} finally {
await store.close();
}
const listTool = api.tools.get("fn_task_list")!;
for (const [callId, params] of [
["list-all-default", {}],
["list-todo-default", { column: "todo" }],
["list-todo-large-limit", { column: "todo", limit: 50 }],
] as const) {
const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir));
expectSingleBoundedTextBlock(result);
expect(result.details.count).toBe(2);
}
});
it("returns explicit text for empty active-column filters on a non-empty board", async () => {
const store = createStore();
await store.init();
try {
await store.createTask({ description: "Finished task keeps the board non-empty", column: "done" });
} finally {
await 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 = createStore();
await store.init();
try {
const first = await store.createTask({ description: "Small todo task one", column: "todo" });
await store.createTask({ description: "Small todo task two", column: "todo", dependencies: [first.id] });
} finally {
await store.close();
}
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"list-small-todo",
{ column: "todo", limit: 50 },
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("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);
});
it("bounds realistic column-filtered listings below the host-safe text budget", async () => {
const store = createStore();
await store.init();
try {
const todoFirst = await store.createTask({
title: realisticTaskTitle("todo", 1),
description: "Realistic todo task 001",
column: "todo",
});
for (let i = 2; i <= 12; i += 1) {
await store.createTask({
title: `${realisticTaskTitle("todo", i)} ${"x".repeat(1_000)}`,
description: `Realistic todo task ${String(i).padStart(3, "0")}`,
column: "todo",
dependencies: [todoFirst.id],
});
}
for (let i = 1; i <= 8; i += 1) {
await store.createTask({
title: `${realisticTaskTitle("triage", i)} ${"x".repeat(1_000)}`,
description: `Realistic triage task ${String(i).padStart(3, "0")}`,
});
}
for (let i = 1; i <= 6; i += 1) {
await store.createTask({
title: `${realisticTaskTitle("done", i)} ${"x".repeat(1_000)}`,
description: `Realistic done task ${String(i).padStart(3, "0")}`,
column: "done",
});
}
} finally {
await store.close();
}
const listTool = api.tools.get("fn_task_list")!;
const broadResult = await listTool.execute(
"list-realistic-broad",
{ limit: 20 },
undefined,
undefined,
makeCtx(tmpDir),
);
expectSingleBoundedTextBlock(broadResult);
expect(broadResult.content.some((block: any) => block.type === "image")).toBe(false);
expect(broadResult.content[0].text).toContain("Planning (8):");
expect(broadResult.details.count).toBe(26);
for (const { callId, params, header, ids } of [
{
callId: "list-realistic-todo",
params: { column: "todo", limit: 12 },
header: "Todo (12):",
ids: ["FN-001", "FN-002"],
},
{
callId: "list-realistic-triage",
params: { column: "triage", limit: 8 },
header: "Planning (8):",
ids: ["FN-013", "FN-014"],
},
{
callId: "list-realistic-done",
params: { column: "done", limit: 6 },
header: "Done (6):",
ids: ["FN-021", "FN-022"],
},
] as const) {
const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir));
const text = result.content[0].text;
expectSingleBoundedTextBlock(result);
expect(result.content.some((block: any) => block.type === "image")).toBe(false);
expect(text).toContain(header);
for (const id of ids) {
expect(text).toContain(id);
}
expect(text).toContain("truncated to fit; narrow with column/limit");
expect(result.details.count).toBe(26);
}
});
it("bounds broad listings as a single plain-text block", async () => {
const store = createStore();
await store.init();
try {
for (let i = 1; i <= 15; i += 1) {
await store.createTask({
title: `Planning task ${String(i).padStart(3, "0")} ${"x".repeat(1_600)}`,
description: `Large planning task ${String(i).padStart(3, "0")}`,
});
}
} finally {
await store.close();
}
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"list-large-broad",
{ limit: 10 },
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.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(text).toContain("Planning (15):");
expect(text).toContain("FN-001");
expect(text).toContain("truncated to fit; narrow with column/limit");
expect(result.details.count).toBe(15);
});
/*
FNXC:CliTests 2026-07-04-13:50:
FN-7530 split the sibling "executes with @fusion/core resolved through the built dist barrel" case (formerly directly below this test) into packages/cli/src/__tests__/extension-dist-barrel.test.ts. That test's own in-test dist-barrel recompilation (vi.resetModules + vi.importActual of the built @fusion/core dist barrel) is CPU-bound and timeout-prone under 4-shard CI contention (FN-6483/FN-6705/FN-6795/FN-6839/FN-7447 same signature); isolating it kept the ~68 stable tests in this file on the default lane while only the isolated file carries its own quarantine entry. This test covers the identical truncation invariant against the source-aliased @fusion/core.
*/
it("bounds large column-filtered listings as a single plain-text block", async () => {
const store = createStore();
await store.init();
try {
const first = await store.createTask({
title: `Todo task 001 ${"x".repeat(300)}`,
description: "Large todo task 001",
column: "todo",
});
for (let i = 2; i <= 20; i += 1) {
await store.createTask({
title: `Todo task ${String(i).padStart(3, "0")} ${"x".repeat(300)}`,
description: `Large todo task ${String(i).padStart(3, "0")}`,
column: "todo",
dependencies: [first.id],
});
}
} finally {
await store.close();
}
const listTool = api.tools.get("fn_task_list")!;
const result = await listTool.execute(
"list-large-todo",
{ column: "todo", limit: 20 },
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.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
expect(text).toContain("Todo (20):");
expect(text).toContain("FN-001");
expect(text).toContain("FN-002");
expect(text).toContain("[deps: FN-001]");
expect(text).toContain("truncated to fit; narrow with column/limit");
expect(result.details.count).toBe(20);
});
it("degrades to bounded text when formatter exports are unavailable", () => {
const boardLinesWithoutParams = [
"Planning (2):",
` FN-001 Planning task ${"x".repeat(6_000)}`,
` FN-002 Planning task ${"x".repeat(6_000)}`,
"",
];
const boardLinesWithColumnAndLimit = [
"Todo (2):",
` FN-003 Todo task ${"x".repeat(6_000)}`,
" ... and 1 more",
"",
];
/*
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.
*/
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);
}
}
});
});
it("returns structured details for invalid task assignment", async () => {
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-bad-agent",
{ description: "bad assignment", agentId: "agent-does-not-exist" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.error).toContain("not found");
expect(result.content[0].text).toContain("not found");
});
it("returns structured details when assignment targets ephemeral agents", async () => {
const ephemeralId = await seedAgent(tmpDir, { ephemeral: true, name: "temp-worker" });
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-ephemeral",
{ description: "ephemeral assignment", agentId: ephemeralId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.error).toContain("ephemeral/runtime agent");
expect(result.content[0].text).toContain(ephemeralId);
});
it("returns explicit collision error when fn_task_create hits an existing task id", async () => {
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-collision",
{ description: "collision task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
expect(result.details.error).toContain("Task ID already exists: FN-001");
createSpy.mockRestore();
});
it("fn_task_create allows durable engineer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const engineer = await agentStore.createAgent({ name: "engineer-create", role: "engineer" });
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-role-check-engineer",
{ description: "create with engineer", agentId: engineer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.content[0].text).toContain(`Assigned to: ${engineer.id}`);
});
it("fn_task_create rejects reviewer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer-create", role: "reviewer" });
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-role-check",
{ description: "create with reviewer", agentId: reviewer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
it("fn_task_update rejects reviewer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer", role: "reviewer" });
const store = createStore();
await store.init();
const task = await store.createTask({ description: "needs owner", column: "todo" });
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"update-role-check",
{ id: task.id, agentId: reviewer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
describe("FN-3799 assignment normalization", () => {
it("FN-3799: treats empty-string agentId as unassigned on create", async () => {
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-empty-agent",
{ description: "Task without assignee", agentId: "" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.assignedAgentId).toBeUndefined();
expect(result.content[0].text).not.toContain("Assigned to:");
expect(result.content[0].text).not.toContain("Agent not found");
});
it("clears task assigned agent ID with empty string", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(
"create-assigned",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"update-clear-empty",
{ id: created.details.taskId, agentId: "" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain(`Updated ${created.details.taskId}`);
expect(result.details.updatedFields).toEqual(["agentId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute(
"show-cleared-empty",
{ id: created.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("clears task assigned agent ID with whitespace", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(
"create-assigned-whitespace",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
await updateTool.execute(
"update-clear-whitespace",
{ id: created.details.taskId, agentId: " " },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute(
"show-cleared-whitespace",
{ id: created.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("clears task assigned agent ID with literal null string", async () => {
const agentId = await seedAgent(tmpDir);
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(
"create-assigned-null-string",
{ description: "Original", agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
await updateTool.execute(
"update-clear-null-string",
{ id: created.details.taskId, agentId: "null" },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute(
"show-cleared-null-string",
{ id: created.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.details.task.assignedAgentId).toBeUndefined();
});
it("returns readable unknown-agent errors with the invalid id", async () => {
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(
"create-for-error",
{ description: "Original" },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"update-unknown-agent",
{ id: created.details.taskId, agentId: "agent-does-not-exist" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe("Agent agent-does-not-exist not found");
});
it("clears node override with empty string", async () => {
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(
"create-node-task",
{ description: "Original" },
undefined,
undefined,
makeCtx(tmpDir),
);
const updateTool = api.tools.get("fn_task_update")!;
const setNode = await updateTool.execute(
"set-node",
{ id: created.details.taskId, nodeId: "node-123" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(setNode.isError).not.toBe(true);
const clearNode = await updateTool.execute(
"clear-node",
{ id: created.details.taskId, nodeId: "" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(clearNode.content[0].text).toContain(`Updated ${created.details.taskId}`);
expect(clearNode.details.updatedFields).toEqual(["nodeId"]);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute(
"show-cleared-node",
{ id: created.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.details.task.nodeId).toBeUndefined();
});
// FNXC:StateMachine 2026-07-07-12:00: FN-7641 Signature 2 CLI regression — nodeId='end'
// must finalize-on-proof or return an explicit isError, never a silent "Updated" no-op
// (NEXT-322 / NEXT-375 / NEXT-340).
it("finalizes an in-review task to done when setting nodeId='end' with merge proof", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({ description: "out-of-band merge repro" });
await store.updateTask(task.id, { steps: [{ name: "Only step", status: "done" }] });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { mergeDetails: { mergeConfirmed: true } });
store.close();
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"finalize-node-end",
{ id: task.id, nodeId: "end" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("show-finalized", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.column).toBe("done");
expect(show.details.task.nodeId).toBe("end");
});
it("returns an explicit isError instead of a silent no-op when setting nodeId='end' without merge proof", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({ description: "no proof repro" });
await store.updateTask(task.id, { steps: [{ name: "Only step", status: "done" }] });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
store.close();
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"reject-node-end",
{ id: task.id, nodeId: "end" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text.toLowerCase()).toContain("merge");
const showTool = api.tools.get("fn_task_show")!;
const show = await showTool.execute("show-rejected", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(show.details.task.column).toBe("in-review");
expect(show.details.task.nodeId).toBeUndefined();
});
});
describe("fn_task_retry", () => {
const nonZeroRetryCounters = Object.fromEntries(
MANUAL_RETRY_RESET_COUNTER_KEYS.map((key, index) => [key, index + 1]),
);
const expectRetryCountersReset = (task: Awaited<ReturnType<TaskStore["getTask"]>>) => {
expect(task).toBeTruthy();
if (!task) return;
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {
expect(task[key] ?? 0).toBe(0);
}
expect(task.nextRecoveryAt ?? null).toBeNull();
expect(task.retrySummary?.total ?? 0).toBe(0);
};
it("moves merge-active missing-worktree session failures to todo with phantom metadata cleared", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "missing-worktree merge-active task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "pending" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "merging",
error: "Refusing to start coding agent in missing worktree: /tmp/fusion-missing-worktree",
worktree: "/tmp/fusion-missing-worktree",
branch: `fusion/${task.id}`,
sessionFile: "/tmp/fusion-session.json",
mergeRetries: 3,
worktreeSessionRetryCount: 3,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-missing-worktree-merge-active", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.worktree).toBeFalsy();
expect(updated?.branch).toBeFalsy();
expect(updated?.sessionFile).toBeFalsy();
expect(updated?.steps[0].status).toBe("done");
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("rejects unrelated merge-active tasks", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({ title: "ordinary merge-active task", description: "test", column: "todo" });
await store.updateTask(task.id, { steps: [{ name: "Step 0", status: "done" }] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: "merging", error: "ordinary merge still running", mergeRetries: 2 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-unrelated-merge-active", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not in a retryable state");
});
it("clears the deadlock auto-pause for execution-failed in-review retries", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "deadlock-paused execution-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "executor stalled after deadlock pause",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 0,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-deadlock-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.paused).toBeUndefined();
expect(updated?.pausedReason).toBeUndefined();
expect(updated?.steps[1].status).toBe("in-progress");
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "execution-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "429 rate limited",
mergeRetries: 9,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps[1].status).toBe("in-progress");
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(9);
});
it("moves zero-step execution-failed in-review task to todo and clears failure state", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "zero-step execution-failed task",
description: "test",
column: "todo",
});
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step execution-failed task\n\nNo steps yet.\n");
await store.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: "failed", error: "executor crashed", mergeRetries: 0, steps: [] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-zero-step-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expect(updated?.mergeRetries).toBe(0);
});
it("clears the deadlock auto-pause for merge-failed in-review retries", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "deadlock-paused merge-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge deadlock",
paused: true,
pausedReason: "in-review-stall-deadlock",
mergeRetries: 3,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-deadlock-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.paused).toBeUndefined();
expect(updated?.pausedReason).toBeUndefined();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("does not clear manual pauses for merge-failed in-review retries", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "user-paused merge-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge deadlock",
paused: true,
pausedReason: "manual",
mergeRetries: 3,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-user-paused-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.paused).toBe(true);
expect(updated?.pausedReason).toBe("manual");
expect(updated?.status).toBeFalsy();
expect(updated?.mergeRetries).toBe(0);
});
it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "merge-failed task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge conflict",
mergeRetries: 3,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("keeps zero-step merge-failed in-review task with prior merge attempts in-review and resets merge state", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "zero-step merge-failed task",
description: "test",
column: "todo",
});
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# zero-step merge-failed task\n\nNo steps yet.\n");
await store.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, {
status: "failed",
error: "merge conflict",
mergeRetries: 2,
steps: [],
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-zero-step-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
it("moves status-none in-review task with incomplete steps to todo preserving progress", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "status-none execution-stalled task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, error: "stalled without failed status", mergeRetries: 5 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps[1].status).toBe("in-progress");
expect(updated?.mergeRetries).toBe(5);
});
it("moves status-none zero-step in-review task with no merge attempts to todo", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "status-none zero-step execution-stalled task",
description: "test",
column: "todo",
});
await writeFile(join(tmpDir, ".fusion", "tasks", task.id, "PROMPT.md"), "# status-none zero-step execution-stalled task\n\nNo steps yet.\n");
await store.updateTask(task.id, { steps: [] });
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, error: "stalled before planning steps", mergeRetries: 0, steps: [] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-zero-step-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.steps).toEqual([]);
expect(updated?.mergeRetries).toBe(0);
});
it("keeps status-none in-review task with prior merge attempts in-review and resets merge state", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "status-none merge-stalled task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, error: "merge retry exhausted without failed status", mergeRetries: 2 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("in-review");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expect(updated?.mergeRetries).toBe(0);
});
it("rejects status-none in-review task with completed steps and no merge attempts", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "status-none completed task with no merge attempts",
description: "test",
column: "todo",
});
await store.updateTask(task.id, {
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
],
});
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-no-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not in a retryable state");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("in-review");
expect(updated?.mergeRetries).toBe(0);
});
it("rejects non-review task with status none", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "status-none todo task",
description: "test",
column: "todo",
});
await store.updateTask(task.id, { status: null, steps: [{ name: "Step 0", status: "pending" }] });
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-status-none-todo", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not in a retryable state");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
});
it("moves non-review failed task to todo and resets all retry counters", async () => {
const store = createStore();
await store.init();
const task = await store.createTask({
title: "failed in-progress task",
description: "test",
column: "todo",
});
await store.moveTask(task.id, "in-progress");
await store.updateTask(task.id, {
status: "failed",
error: "verification failed",
mergeRetries: 8,
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
...nonZeroRetryCounters,
});
const retryTool = api.tools.get("fn_task_retry")!;
const result = await retryTool.execute("retry-generic", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBeFalsy();
expect(result.details.newColumn).toBe("todo");
const updated = await store.getTask(task.id);
expect(updated?.column).toBe("todo");
expect(updated?.status).toBeFalsy();
expect(updated?.error).toBeFalsy();
expectRetryCountersReset(updated);
expect(updated?.mergeRetries).toBe(0);
});
});
describe("fn_list_agents", () => {
it("returns agent list", async () => {
await seedAgent(tmpDir, { name: "alpha-agent" });
await seedAgent(tmpDir, { name: "beta-agent" });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("alpha-agent");
expect(result.content[0].text).toContain("beta-agent");
expect(result.details.count).toBeGreaterThanOrEqual(2);
});
it("filters by role", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
await agentStore.createAgent({ name: "exec-agent", role: "executor", metadata: {} });
await agentStore.createAgent({ name: "review-agent", role: "reviewer", metadata: {} });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-2", { role: "executor" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("exec-agent");
expect(result.content[0].text).not.toContain("review-agent");
});
it("filters by state", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const active = await agentStore.createAgent({ name: "active-agent", role: "executor", metadata: {} });
await agentStore.updateAgentState(active.id, "active");
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-3", { state: "active" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("active-agent");
expect(result.details.agents.every((a: any) => a.state === "active")).toBe(true);
});
it("excludes ephemeral agents by default", async () => {
const ephemeralId = await seedAgent(tmpDir, { ephemeral: true, name: "eph-agent" });
await seedAgent(tmpDir, { name: "real-agent" });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-4", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).not.toContain("eph-agent");
expect(result.content[0].text).toContain("real-agent");
expect(result.details.agents.every((a: any) => a.id !== ephemeralId)).toBe(true);
});
it("shows current task column context for parked, active, terminal, and missing links", async () => {
const store = createStore();
await store.init();
const triageTask = await store.createTask({ description: "Planning link", column: "triage" });
const activeTask = await store.createTask({ description: "Active link", column: "in-progress" });
const doneTask = await store.createTask({ description: "Done link", column: "done" });
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const triageAgent = await agentStore.createAgent({ name: "triage-linked", role: "executor", metadata: {} });
const activeAgent = await agentStore.createAgent({ name: "active-linked", role: "executor", metadata: {} });
const doneAgent = await agentStore.createAgent({ name: "done-linked", role: "executor", metadata: {} });
const missingAgent = await agentStore.createAgent({ name: "missing-linked", role: "executor", metadata: {} });
await agentStore.syncExecutionTaskLink(triageAgent.id, triageTask.id);
await agentStore.syncExecutionTaskLink(activeAgent.id, activeTask.id);
await agentStore.syncExecutionTaskLink(doneAgent.id, doneTask.id);
await agentStore.syncExecutionTaskLink(missingAgent.id, "FN-404404");
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-current-task-context", {}, undefined, undefined, makeCtx(tmpDir));
const text = result.content[0].text;
expect(text).toContain(`Current Task: ${triageTask.id} (triage)`);
expect(text).toContain(`Current Task: ${activeTask.id} (in-progress)`);
expect(text).toContain(`Current Task: ${doneTask.id} (not active — done)`);
expect(text).toContain("Current Task: FN-404404 (unresolved)");
expect(text).not.toMatch(new RegExp(`Current Task: ${triageTask.id}(?! \\()`));
});
it("returns empty list message when no agents", async () => {
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-5", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("No agents found");
expect(result.details.count).toBe(0);
});
});
describe("research tools", () => {
it.each([
"fn_research_run",
"fn_research_list",
"fn_research_get",
"fn_research_cancel",
"fn_research_retry",
])("%s uses disambiguated cited-research wording", (toolName) => {
const tool = api.tools.get(toolName)!;
expect(tool.description).toMatch(/cited-research pipeline/i);
if (/experiment loop/i.test(tool.description)) {
expect(tool.description).toMatch(/not\s+.*experiment loop/i);
}
});
it("fn_research_run treats builtin as configured when no provider is explicitly set", async () => {
const store = createStore();
await store.init();
await store.updateGlobalSettings({
researchGlobalEnabled: true,
experimentalFeatures: { researchView: true } as Record<string, boolean>,
});
await store.updateSettings({
researchEnabled: true,
researchSettings: { enabled: true },
});
const tool = api.tools.get("fn_research_run")!;
const result = await tool.execute(
"research-run-builtin",
{ query: "builtin default" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.setup).toBeNull();
expect(result.details.status).toBe("queued");
});
it("fn_research_list status parameter matches RESEARCH_RUN_STATUSES", () => {
const tool = api.tools.get("fn_research_list") as any;
const statusSchema = tool.parameters.properties.status;
const enumValues = statusSchema.enum ?? statusSchema.anyOf?.[0]?.enum;
expect(enumValues).toEqual([...RESEARCH_RUN_STATUSES]);
});
it("fn_research_run preserves fire-and-forget behavior when wait_for_completion is false", async () => {
const store = await enableResearch(tmpDir);
try {
const tool = api.tools.get("fn_research_run")!;
const result = await tool.execute(
"research-run-ff",
{ query: "test query", wait_for_completion: false },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Start the project engine to process pending runs");
expect(result.details.status).toBe("queued");
} finally {
await store.close();
}
});
it("fn_research_run waits and returns terminal run details when wait_for_completion is true", async () => {
const store = await enableResearch(tmpDir);
try {
const tool = api.tools.get("fn_research_run")!;
const researchStore = store.getResearchStore();
const settleRunToCompleted = () => {
const queuedRun = researchStore.listRuns({ limit: 1 })[0];
if (!queuedRun) {
return false;
}
if (queuedRun.status === "completed") {
return true;
}
if (queuedRun.status === "queued") {
researchStore.updateRun(queuedRun.id, { status: "running" });
}
researchStore.updateRun(queuedRun.id, {
status: "completed",
results: { summary: "done", findings: [{ heading: "h1", content: "f1", sources: [] }], citations: [] },
});
return true;
};
/*
FNXC:CliTests 2026-06-19-11:06:
The wait-for-completion regression must settle its synthetic run after the tool creates it; starting the completer before creation can miss the run and consume the whole 5s Vitest budget.
*/
const resultPromise = tool.execute(
"research-run-wait",
{ query: "terminal query", wait_for_completion: true, max_wait_ms: 3000 },
undefined,
undefined,
makeCtx(tmpDir),
);
for (let attempt = 0; attempt < 50 && !settleRunToCompleted(); attempt += 1) {
await delay(10);
}
const result = await resultPromise;
expect(result.details.status).toBe("completed");
expect(result.details.summary).toBe("done");
expect(result.content[0].text).toContain("is completed");
} finally {
await store.close();
}
});
});
describe("fn_delegate_task", () => {
it("delegates task to agent", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-target" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-1",
{ agent_id: agentId, description: "Do important work" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("delegate-target");
expect(result.content[0].text).toContain(agentId);
expect(result.details.agentId).toBe(agentId);
expect(result.details.agentName).toBe("delegate-target");
expect(result.details.taskId).toBeTruthy();
// Verify task was actually created
const store = createStore();
await store.init();
const task = await store.getTask(result.details.taskId);
expect(task).toBeTruthy();
expect(task!.assignedAgentId).toBe(agentId);
expect(task!.column).toBe("todo");
});
it("delegates task with workflow_id selected and materialized", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-workflow-target" });
const workflowId = await seedWorkflow(tmpDir, "Delegate workflow");
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-workflow",
{ agent_id: agentId, description: "Do workflow work", workflow_id: workflowId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.content[0].text).toContain(`(workflow: ${workflowId})`);
const { task, selection } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("todo");
expect(task.assignedAgentId).toBe(agentId);
expect(selection?.workflowId).toBe(workflowId);
expect(task.enabledWorkflowSteps).toHaveLength(2);
});
it("rejects unknown agent", async () => {
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-2",
{ agent_id: "agent-no-such", description: "Will fail" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
it("rejects ephemeral agent", async () => {
const ephemeralId = await seedAgent(tmpDir, { ephemeral: true, name: "eph-delegate" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-3",
{ agent_id: ephemeralId, description: "Will fail" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("ephemeral/runtime agent");
});
it("allows durable engineer delegate target without override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const engineer = await agentStore.createAgent({ name: "delegate-engineer", role: "engineer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-eng",
{ agent_id: engineer.id, description: "Engineer routing" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.agentId).toBe(engineer.id);
});
it("rejects reviewer delegate target without override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "delegate-reviewer", role: "reviewer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-1",
{ agent_id: reviewer.id, description: "Will fail role policy" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
it("allows non-executor delegate target with override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "delegate-reviewer-override", role: "reviewer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-2",
{ agent_id: reviewer.id, description: "Intentional override", override: true },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.agentId).toBe(reviewer.id);
const store = createStore();
await store.init();
const task = await store.getTask(result.details.taskId);
expect(task.sourceMetadata).toMatchObject({ executorRoleOverride: true });
const selected = await store.selectNextTaskForAgent(reviewer.id, { id: reviewer.id, role: reviewer.role });
expect(selected?.task.id).toBe(task.id);
});
it("wires dependencies correctly", async () => {
const agentId = await seedAgent(tmpDir, { name: "dep-agent" });
// Create a real task to use as a dependency
const store = createStore();
await store.init();
const depTask = await store.createTask({ description: "Prerequisite", column: "todo" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-4",
{ agent_id: agentId, description: "Dependent work", dependencies: [depTask.id] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain(depTask.id);
const task = await store.getTask(result.details.taskId);
expect(task!.dependencies).toEqual([depTask.id]);
});
});
describe("fn_agent_show", () => {
it("shows agent by ID", async () => {
const agentId = await seedAgent(tmpDir, { name: "show-agent" });
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-1", { id: agentId }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("show-agent");
expect(result.content[0].text).toContain(agentId);
expect(result.details.agent.id).toBe(agentId);
});
it("shows agent by name", async () => {
await seedAgent(tmpDir, { name: "resolve-by-name" });
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-2", { id: "resolve-by-name" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("resolve-by-name");
expect(result.details.agent.name).toBe("resolve-by-name");
});
it("shows current task column context for linked agents", async () => {
const store = createStore();
await store.init();
const triageTask = await store.createTask({ description: "Show linked task", column: "triage" });
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const agent = await agentStore.createAgent({ name: "show-linked", role: "executor", metadata: {} });
await agentStore.syncExecutionTaskLink(agent.id, triageTask.id);
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-current-task-context", { id: agent.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain(`Current Task: ${triageTask.id} (triage)`);
expect(result.content[0].text).not.toMatch(new RegExp(`Current Task: ${triageTask.id}(?! \\()`));
});
it("returns error for unknown agent", async () => {
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-3", { id: "no-such-agent" }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
it("shows reports-to and direct reports", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const manager = await agentStore.createAgent({ name: "the-manager", role: "executor", metadata: {} });
const report = await agentStore.createAgent({
name: "the-report",
role: "executor",
reportsTo: manager.id,
metadata: {},
});
const tool = api.tools.get("fn_agent_show")!;
// Check manager sees direct reports
const mgrResult = await tool.execute("as-4a", { id: manager.id }, undefined, undefined, makeCtx(tmpDir));
expect(mgrResult.content[0].text).toContain("the-report");
expect(mgrResult.details.directReports.length).toBeGreaterThan(0);
// Check report sees reports-to
const rptResult = await tool.execute("as-4b", { id: report.id }, undefined, undefined, makeCtx(tmpDir));
expect(rptResult.content[0].text).toContain("the-manager");
});
});
describe("fn_agent_org_chart", () => {
it("returns full tree", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
await agentStore.createAgent({ name: "ceo", role: "executor", metadata: {} });
await agentStore.createAgent({ name: "worker", role: "executor", metadata: {} });
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("ceo");
expect(result.content[0].text).toContain("worker");
expect(result.details.count).toBeGreaterThanOrEqual(2);
});
it("returns subtree by root agent", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const manager = await agentStore.createAgent({ name: "org-manager", role: "executor", metadata: {} });
await agentStore.createAgent({ name: "org-report", role: "executor", reportsTo: manager.id, metadata: {} });
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-2", { root_agent_id: manager.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("org-manager");
expect(result.content[0].text).toContain("org-report");
});
it("returns empty message when no agents", async () => {
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-3", {}, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("No agents found");
expect(result.details.count).toBe(0);
});
it("returns single agent for lone agent", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const lone = await agentStore.createAgent({ name: "lone-agent", role: "executor", metadata: {} });
const tool = api.tools.get("fn_agent_org_chart")!;
const result = await tool.execute("oc-4", { root_agent_id: lone.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("lone-agent");
});
});
});