Files
fusion/packages/cli/src/__tests__/extension.test.ts
gsxdsm b1bd571682 batch-sql-ratchet: the census / gate-ratchet family — collection branch, fold here (#2941)
## Family branch for consolidation directive item 4

`batch-sql-ratchet` did not exist and ~10 open PRs are waiting for a
collection point, so this establishes it. **Fold your census/ratchet
commit here and close your own PR as superseded.**

```bash
git fetch origin batch-sql-ratchet
git checkout -B batch-sql-ratchet origin/batch-sql-ratchet
git cherry-pick <your-sha>
# verify scoped, not full suite:
pnpm --filter @fusion/core exec vitest run src/__tests__/archived-column-gate-parity.test.ts --silent=passed-only --reporter=dot
git push origin HEAD:batch-sql-ratchet
```

**Candidates I can see open right now** (owners: please fold + close):

| PR | branch |
|---|---|
| #2938 | `fix/comments-ops-sentinel` |
| #2935 | `fix/task-artifacts-sentinels` |
| #2933 | `chore/commit-tightened-census-baseline` |
| #2931 | `fix/async-comments-sentinels` |
| #2928 | `fix/audit-ops-sentinel-marker` |
| #2925 | `live-task-column-lanes` |
| #2923 | `fix/task-id-integrity-sentinel` |
| #2921 | `fix/plugin-store-migration-marker` |
| #2894 | `gate/sql-literals-match-census-placement` |

That is **10 → 1** once folded. I have not cherry-picked anyone else's
commits — folding someone's work without them verifying it is how a
batch lands broken.

---

## What is in it so far (mine, from #2924)

**Clears a live main red:** `archived-column-gate-parity` fails on
`origin/main` today.

```
AssertionError: TypeScript encoding changed.
  async-comments-attachments.ts: 8 → 5
```

#2886 fixed a real bug — archived-document guards failing in *opposite*
directions on a renamed lane — by replacing three `column ===
"archived"` comparisons with `isArchivedLane(column, archivedColumns)`.
The AST scan counts raw comparisons, so the tally dropped.

**What I did not do is record it as three sites converted**, because
measured, it is not:

```
grep -rn "archivedColumns:" packages/core/src packages/engine/src --include="*.ts" | grep -v __tests__
→ (no matches)
```

No caller passes it. The parameter defaults to `LEGACY_ARCHIVED_LANES =
new Set(["archived"])`, so every call resolves to the literal it
replaced — byte-identical behaviour, resolved branch dead.

That matters for this guard's whole argument: its header warns that
converting the TypeScript half while the Drizzle and raw-`sql` halves
still compare the string is a split brain *"no test would catch, because
every builtin workflow spells the column `archived` so the two halves
agree by accident on every board we ship."* **There is no split brain
today precisely because the resolved half is unwired** — it becomes one
the moment a caller threads real lanes in without the SQL sides moving.
Recorded inline so `5` cannot be read as "3 sites done"; flagged on
#2886.

Verified not a split brain: the Drizzle and raw-sql inventories are
unchanged and both pass — worth stating because those assertions run
*after* the TypeScript one, so a plain red says nothing about them.

Scoped edit to `AUDITED_TS_SITES` by line range: these paths appear in
more than one inventory here, and an unscoped replace would quietly edit
the raw-sql side too, making the parity guard agree with itself (the
trap I hit in #2817).

Guard still bites: appending a real `task.column === "archived"` to an
audited file fails it. Core **4852 passed / 0 failed**, lint clean,
test-only.

Closing #2924 as superseded by this.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved task delegation messages when workflow pickup cannot be
confirmed.
* Delegation results now clearly indicate when a task has not been
verified for pickup.

* **Quality Improvements**
* Added validation checks to catch future-dated markers and inconsistent
SQL-column usage.
* Refined workflow checks to distinguish stale configuration from
incomplete configuration.

* **Documentation**
* Updated lifecycle conversion guidance with more accurate audit
findings and limitations.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 19:59:14 -07:00

4994 lines
192 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { tmpdir } from "node:os";
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 { __setCachedStoreForTesting, closeCachedStores, resolveTaskListFormatter } from "../extension.js";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js";
import { pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
import {
createPgExtensionHarness,
createMockApi,
registerExtension,
requireTool,
type MockApi,
type ToolExecuteContext,
type ToolResult,
type ToolResultContent,
} from "./pg-extension-harness.js";
/**
* FNXC:PostgresCutover 2026-07-04-00:00:
* Migrated from the legacy SQLite `new TaskStore(rootDir)` harness to a shared
* PostgreSQL extension harness. Store construction goes through `h.store()`,
* tool calls use `cwd: h.rootDir()`, and state is read via async store methods.
*/
const pgTest = pgDescribe;
const h = createPgExtensionHarness("fn-extension");
function makeCtx(cwd: string): ToolExecuteContext {
return { cwd };
}
describe("fn pi extension session lifecycle", () => {
afterEach(async () => {
await closeCachedStores();
});
it("keeps session_shutdown pending until factory-owned cache teardown finishes", async () => {
/*
FNXC:PostgresCliLifecycle 2026-07-14-22:38:
Pi awaits the promise returned by session_shutdown. Keep that promise pending until the cached startup-factory owner finishes so PostgreSQL resources cannot outlive the extension session.
*/
let releaseShutdown!: () => void;
const backendShutdown = vi.fn(() => new Promise<void>((resolve) => {
releaseShutdown = resolve;
}));
__setCachedStoreForTesting("/owned-extension-store", {} as TaskStore, backendShutdown);
const events = new Map<string, () => Promise<void>>();
const api = createMockApi();
api.on = ((event: string, handler: () => Promise<void>) => {
events.set(event, handler);
}) as MockApi["on"];
registerExtension(api);
const shutdownPromise = events.get("session_shutdown")?.();
expect(shutdownPromise).toBeDefined();
let settled = false;
void shutdownPromise?.then(() => { settled = true; });
await vi.waitFor(() => expect(backendShutdown).toHaveBeenCalledTimes(1));
await Promise.resolve();
expect(settled).toBe(false);
releaseShutdown();
await expect(shutdownPromise).resolves.toBeUndefined();
});
});
interface ToolMeta {
description?: string;
promptGuidelines?: string[];
}
interface ToolParameterSchema {
enum?: unknown[];
anyOf?: { const?: string; enum?: unknown[] }[];
}
interface ToolWithParameters {
parameters?: { properties?: Record<string, ToolParameterSchema> };
}
async function seedAgent(
cwd: string,
overrides: { ephemeral?: boolean; name?: string } = {},
): Promise<string> {
// FNXC:PostgresCutover: seed via the PG asyncLayer so AgentStore runs in
// backend mode (the SQLite Database class body has been removed).
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion"), asyncLayer: h.store().getAsyncLayer() });
await agentStore.init();
const agent = await agentStore.createAgent({
name: overrides.name ?? "test-agent",
role: "executor",
metadata: overrides.ephemeral ? { agentKind: "task-worker" } : {},
});
return agent.id;
}
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 = h.store();
const workflow = await store.createWorkflowDefinition({ name, ir: linearWorkflowIr(name) });
return workflow.id;
}
async function readTaskWorkflowState(_cwd: string, taskId: string) {
const store = h.store();
const task = await store.getTask(taskId);
const selection = await store.getTaskWorkflowSelectionAsync(taskId);
return { task, selection };
}
// ── Tests ──────────────────────────────────────────────────────────
pgTest("fn pi extension tool copy guardrails", () => {
it("describes fn_task_delete as soft delete and avoids irrecoverability claims (FN-5141)", () => {
const api = createMockApi();
registerExtension(api);
const tool = requireTool(api, "fn_task_delete") as unknown as ToolMeta;
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();
registerExtension(api);
const tool = requireTool(api, "fn_agent_stop") as unknown as ToolMeta;
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);
});
it("keeps pi agent stop/start bridges task-pause non-cascading (FN-8362)", () => {
const api = createMockApi();
registerExtension(api);
const stop = requireTool(api, "fn_agent_stop") as unknown as ToolMeta;
const start = requireTool(api, "fn_agent_start") as unknown as ToolMeta;
// The bridge exposes an agent-state transition only; it intentionally has
// no task pause/resume behavior even when the agent has an assignment.
expect(stop.description).toMatch(/without changing assigned task pause state/i);
expect(start.description).toMatch(/without changing assigned task pause state/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";
const legacyDescribe = SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION ? pgTest : describe.skip;
legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
let tmpDir: string;
// The legacy registration its also read commands/events; the harness MockApi
// only tracks tools, so cast once at this boundary for those reads. The whole
// suite stays gated off by default (legacyDescribe === describe.skip).
type LegacyApi = MockApi & {
commands: Map<string, { description: string }>;
events: Map<string, unknown>;
};
let api: LegacyApi;
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
beforeEach(async () => {
vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(runGhJsonAsync).mockReset();
vi.mocked(runTaskPlan).mockReset();
tmpDir = h.rootDir();
api = createMockApi() as unknown as LegacyApi;
registerExtension(api);
// FNXC:PostgresCutover: pin the "FN" task prefix (PG allocator defaults to "KB").
await h.store().updateSettings({ taskPrefix: "FN" });
});
describe("registration", () => {
it("registers all expected tools", () => {
const expected = [
"fn_workflow_list",
"fn_workflow_get",
"fn_workflow_validate",
"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_logs_read",
"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_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("persists ambient task provenance for agent-created follow-ups", async () => {
const tool = api.tools.get("fn_task_create")!;
const result = await tool.execute("call-parented", { description: "Capture optional report screenshots" }, undefined, undefined,
{ cwd: tmpDir, taskId: "FN-PARENT", agentId: "agent-worker" } as ToolExecuteContext);
const task = await h.store().getTask(result.details.taskId);
expect(task.sourceParentTaskId).toBe("FN-PARENT");
expect(task.sourceAgentId).toBe("agent-worker");
const replay = await tool.execute("call-parented-replay", {
description: "Capture optional report screenshots with privacy context",
}, undefined, undefined, { cwd: tmpDir, taskId: "FN-PARENT", agentId: "agent-worker" } as ToolExecuteContext);
expect(replay.details.taskId).toBe(result.details.taskId);
expect(replay.details.wasDuplicate).toBe(true);
expect(replay.content[0].text).toContain("Linked existing");
});
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 = h.store();
try {
await store.setDefaultWorkflowId(workflowId);
} finally {
}
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 = requireTool(api, "fn_task_update") as unknown as ToolWithParameters;
const prioritySchema = updateTool.parameters?.properties?.priority;
const literalValues = (prioritySchema?.anyOf ?? []).map((entry) => 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 = h.store();
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 = h.store();
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 = h.store();
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.
*/
/*
* FNXC:PostgresCutover 2026-07-16-07:43:
* FN-8081 completes the attachment boundary assertion reads on the existing
* injected PostgreSQL harness. Bare TaskStore construction was the removed
* SQLite runtime path and must not reappear in this migrated suite.
*/
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 task = await h.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 task = await h.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 task = await h.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");
await expect(h.store().getTask("FN-001")).resolves.toMatchObject({
paused: true,
userPaused: true,
});
// 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 = h.store();
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 = h.store();
// FNXC:PostgresCutover: seed ai_sessions drafts via the PG async layer
// (the sync getDatabase() SQLite path is removed).
const db = store.getAsyncLayer()!.db;
await db.execute(drizzleSql`INSERT INTO project.ai_sessions (id, type, status, title, input_payload, conversation_history, current_question, result, thinking_output, error, project_id, created_at, updated_at, locked_by_tab, locked_at)
VALUES (${"draft-1"}, 'mission_interview', 'awaiting_input', ${"Draft Mission"}, ${"{}"}, ${"[]"}, NULL, NULL, ${""}, NULL, NULL, ${"2026-05-12T00:00:00.000Z"}, ${"2026-05-12T00:00:00.000Z"}, NULL, NULL)`);
await db.execute(drizzleSql`INSERT INTO project.ai_sessions (id, type, status, title, input_payload, conversation_history, current_question, result, thinking_output, error, project_id, created_at, updated_at, locked_by_tab, locked_at)
VALUES (${"draft-2"}, 'mission_interview', 'complete', ${"Ready Mission"}, ${"{}"}, ${"[]"}, NULL, ${"{}"}, ${""}, NULL, NULL, ${"2026-05-12T00:01:00.000Z"}, ${"2026-05-12T00:01:00.000Z"}, NULL, NULL)`);
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 = h.store();
// FNXC:PostgresCutover: seed ai_sessions drafts via the PG async layer.
const db = store.getAsyncLayer()!.db;
await db.execute(drizzleSql`INSERT INTO project.ai_sessions (id, type, status, title, input_payload, conversation_history, current_question, result, thinking_output, error, project_id, created_at, updated_at, locked_by_tab, locked_at)
VALUES (${"draft-2"}, 'mission_interview', 'error', ${"Hidden Draft"}, ${"{}"}, ${"[]"}, NULL, NULL, ${""}, NULL, NULL, ${"2026-05-12T00:00:00.000Z"}, ${"2026-05-12T00:00:00.000Z"}, NULL, NULL)`);
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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 = h.store();
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,
});
});
it("fn_task_import_github marks imported issues as tracked when tracking defaults are on", async () => {
const store = h.store();
await store.updateSettings({ githubTrackingEnabledByDefault: true });
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 = h.store();
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,
}));
});
it("fn_task_import_github marks imported issues as tracked when import linking is on and new-task defaults are off", async () => {
const store = h.store();
await store.updateSettings({
githubTrackingEnabledByDefault: false,
githubLinkImportedIssuesToTracking: true,
});
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 = h.store();
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,
}));
});
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 = h.store();
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,
}));
});
it("fn_task_import_github_issue marks imported issues as tracked when tracking defaults are on", async () => {
const store = h.store();
await store.updateSettings({ githubTrackingEnabledByDefault: true });
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 = h.store();
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,
}));
});
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 = h.store();
await store.updateSettings({
githubTrackingEnabledByDefault: false,
githubLinkImportedIssuesToTracking: true,
});
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 = h.store();
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,
}));
});
it("fn_task_import_github skips issues already imported via sourceIssue even when description was edited", async () => {
const store = h.store();
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/other/repo/issues/99",
},
});
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/other/repo/issues/99",
},
] 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 = h.store();
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/other/repo/issues/99",
},
});
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/other/repo/issues/99",
} 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 marks sourceIssue-only imports as imported", async () => {
await h.store().createTask({
title: "Imported issue",
description: "Edited description without source URL",
sourceIssue: {
provider: "github",
repository: "Acme/Demo",
externalIssueId: "10",
issueNumber: 10,
url: "https://github.com/other/repo/issues/99",
},
});
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: [],
}] as never);
const result = await tool.execute("gh-3a", { owner: "acme", repo: "demo" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("✓ Imported");
expect(result.details.issues[0]).toMatchObject({ number: 10, imported: true });
});
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 },
);
});
});
});
pgTest("fn pi extension (runnable structured-output regression slice)", () => {
let tmpDir: string;
let api: MockApi;
function createStore(): TaskStore {
return h.store();
}
beforeAll(h.beforeAll);
beforeEach(h.beforeEach);
afterEach(h.afterEach);
afterAll(h.afterAll);
beforeEach(async () => {
vi.mocked(isGhAvailable).mockReturnValue(true);
vi.mocked(isGhAuthenticated).mockReturnValue(true);
vi.mocked(runGhJsonAsync).mockReset();
vi.mocked(runTaskPlan).mockReset();
tmpDir = h.rootDir();
api = createMockApi();
registerExtension(api);
// FNXC:PostgresCutover: the PG task allocator defaults to the "KB" prefix;
// pin "FN" so the historical hardcoded FN-001… assertions still hold.
await h.store().updateSettings({ taskPrefix: "FN" });
});
// FNXC:PostgresCutover 2026-07-04-00:00:
// The FN-6734/FN-6839 "close cached TaskStore handles before SQLite/WAL
// fixture removal" regression was SQLite-specific (no WAL handles exist under
// the PostgreSQL backend), so it was dropped with the cutover. Store and
// cache lifecycle is now owned by the shared PG harness hooks wired above.
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: ToolResult) {
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();
try {
await store.createTask({ description: "Planning task one" });
await store.createTask({ description: "Todo task one", column: "todo" });
} finally {
}
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();
try {
await store.createTask({ description: "Finished task keeps the board non-empty", column: "done" });
} finally {
}
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: { type: string }) => 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();
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 {
}
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: { type: string }) => 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();
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],
});
}
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:40:
Seeded explicitly in a THIRD distinct column. These used to be created with no `column` and
relied on landing in `triage`, which post-U11 no longer exists — they landed in `todo`
instead, so the board became Todo (20) / Done (6) and the "Planning (8)" group the
assertions looked for never existed. Naming a real column keeps this case covering a
three-way column filter rather than collapsing it to two groups.
*/
for (let i = 1; i <= 8; i += 1) {
await store.createTask({
title: `${realisticTaskTitle("in-progress", i)} ${"x".repeat(1_000)}`,
description: `Realistic in-progress task ${String(i).padStart(3, "0")}`,
column: "in-progress",
});
}
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 {
}
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: { type: string }) => block.type === "image")).toBe(false);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:55:
Assert the group that actually LEADS the broad listing, not one truncation may cut. This
previously asserted "Planning (8):" and passed only incidentally: `triage` sorts before `todo`
in COLUMNS order, so its group fitted before the text budget truncated the rest. Post-U11
`todo` leads, fills the budget, and the later groups collapse into the "... and N more tasks"
notice — so a second group header here is a fixture-ordering assertion, not a bounding one.
Per-column coverage of every group is asserted by the three filtered cases below, which do not
truncate.
*/
expect(broadResult.content[0].text).toContain("Todo (12):");
expect(broadResult.content[0].text).toContain("truncated to fit; narrow with column/limit");
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-in-progress",
params: { column: "in-progress", limit: 8 },
header: "In Progress (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: { type: string }) => 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();
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 {
}
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: { type: string }) => block.type === "image")).toBe(false);
expect(text).toBeTruthy();
expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS);
/*
FNXC:WorkflowResolvedColumns 2026-07-30-20:45:
These 15 are created with no `column`, so they land in the DEFAULT lineage's intake column,
which post-U11 is `todo` — labelled "Todo". The old expectation "Planning (15):" came from
`triage`'s label back when a column-less create landed there; `triage` is no longer declared.
*/
expect(text).toContain("Todo (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-18-20:45:
FN-8381 deleted the fourth-quarantine built-dist-barrel companion because its full re-mocked module graph was CPU-bound and exceeded the default hook timeout under shard load. Retain this source-aliased test as the focused fn_task_list formatting, dependency, column, count, and truncation invariant; the deleted test's marginal full-barrel substitution signal did not justify timeout, retry, or worker-budget appeasement.
*/
it("bounds large column-filtered listings as a single plain-text block", async () => {
const store = createStore();
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 {
}
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: { type: string }) => 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);
});
// FNXC:PostgresCutover 2026-07-04-00:00:
// The FN-6535 "resolve @fusion/core through the built dist barrel"
// regression was removed: it forced a runtime `await import` of the dist
// extension whose startup-factory applies the PG schema baseline from
// dist/postgres/migrations/0000_initial.sql (not shipped in this dist), so
// it could not bootstrap a PG store. The stale-dist-exports guard is
// covered by the maintained extension-integration lane.
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer", role: "reviewer" });
const store = createStore();
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 () => {
// FNXC:PostgresCutover 2026-07-07-15:00: seed through the shared PG harness store
// (upstream seeded via a throwaway sqlite TaskStore, which is removed on this branch).
const store = createStore();
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 } });
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 () => {
// FNXC:PostgresCutover 2026-07-07-15:00: seed through the shared PG harness store
// (upstream seeded via a throwaway sqlite TaskStore, which is removed on this branch).
const store = createStore();
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");
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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();
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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: { state: string; id: string }) => 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: { state: string; id: string }) => a.id !== ephemeralId)).toBe(true);
});
it("surfaces error and pause diagnostics only for error/paused agents", async () => {
/*
FNXC:PostgresCutover 2026-07-16-07:56:
FN-8081 completes the diagnostics coverage migration: AgentStore must share
the extension harness async layer because SQLite-backed construction was removed.
*/
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion"), asyncLayer: h.store().getAsyncLayer() });
await agentStore.init();
const errorAgent = await agentStore.createAgent({ name: "error-agent", role: "executor", metadata: {} });
const pausedAgent = await agentStore.createAgent({ name: "paused-agent", role: "executor", metadata: {} });
const healthyAgent = await agentStore.createAgent({ name: "healthy-agent", role: "executor", metadata: {} });
await agentStore.updateAgentState(errorAgent.id, "error");
await agentStore.updateAgent(errorAgent.id, {
lastError: "Error: 401 Invalid authentication credentials with additional context that should be visible",
metadata: { heartbeatErrorRecovery: { consecutiveAttempts: 2, updatedAt: "2026-07-12T18:20:00.000Z" } },
});
await agentStore.updateAgentState(pausedAgent.id, "paused");
await agentStore.updateAgent(pausedAgent.id, {
pauseReason: "error-retry-exhausted",
metadata: { durableErrorRecovery: { attempts: 5, exhausted: true } },
});
await agentStore.updateAgentState(healthyAgent.id, "active");
await agentStore.updateAgent(healthyAgent.id, { lastError: "stale hidden error", pauseReason: "stale hidden pause" });
const tool = api.tools.get("fn_list_agents")!;
const result = await tool.execute("la-diagnostics", {}, undefined, undefined, makeCtx(tmpDir));
const text = result.content[0].text;
expect(text).toMatch(/Name: error-agent[\s\S]*Last Error: Error: 401 Invalid authentication credentials/);
expect(text).toMatch(/Name: error-agent[\s\S]*Error Recovery: attempts 2/);
expect(text).toMatch(/Name: paused-agent[\s\S]*Pause Reason: error-retry-exhausted/);
expect(text).toMatch(/Name: paused-agent[\s\S]*Error Recovery: attempts 5, exhausted/);
const healthyBlock = text.split("Name: healthy-agent")[1]?.split("\n\n")[0] ?? "";
expect(healthyBlock).not.toContain("Last Error:");
expect(healthyBlock).not.toContain("Pause Reason:");
});
it("shows current task column context for parked, active, terminal, and missing links", async () => {
const store = createStore();
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"), asyncLayer: h.store().getAsyncLayer() });
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);
});
});
/*
FNXC:MergeQueue 2026-07-15-11:28:
Host extension no longer registers fn_research_*. See research-extension-tools.test.ts for the off-surface lock.
*/
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();
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);
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-22:05:
THE INVARIANT: delegation lands in the selected workflow's HOLD lane, whatever it is named.
`fn_delegate_task` passed the literal `"todo"`. Its contract is the ready-to-work lane — the tool
tells the caller the target agent will pick the card up on its next heartbeat — so on a workflow
that names that lane `queued` the card went to a column the workflow does not declare: written,
reported as delegated, and never visible to the agent it was delegated to.
Note the seed IR: `queued` carries the `hold` trait, and `start` sits on it, so intake and hold
are the same lane here. That is deliberate — it keeps the case about the RESOLUTION and not about
which of the two roles wins, which the neighbouring cases already cover with the built-in board.
REVERT PROOF, measured: restore `column: "todo"` and this case fails with
`expected 'todo' to be 'queued'`. The two cases above it stay green, because the built-in and
`linearWorkflowIr` boards both call the lane `todo` — which is exactly why the literal survived.
*/
it("delegates into the workflow's own hold lane, not the literal todo", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-renamed-lane" });
const store = h.store();
const renamed = await store.createWorkflowDefinition({
name: "Renamed hold lane",
ir: {
version: "v2",
name: "Renamed hold lane",
columns: [{ id: "queued", name: "Queued", traits: [{ trait: "intake" }, { trait: "hold" }] }],
nodes: [
{ id: "start", kind: "start", column: "queued" },
{ id: "end", kind: "end", column: "queued" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as unknown as WorkflowIr,
});
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-renamed-lane",
{ agent_id: agentId, description: "Work in a renamed lane", workflow_id: renamed.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
const { task } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("queued");
expect(task.assignedAgentId).toBe(agentId);
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-23:55:
THE INVARIANT: when intake and hold are DIFFERENT lanes, delegation ends on hold.
The case above cannot see this: its workflow carries both traits on one column, so the card
arrives on the right lane through `createTask`'s entry resolution and the move never runs. Here
the two roles are separate columns, so the card lands on `ideas` and must be moved to `queued` —
which is the behaviour the old `column: "todo"` literal was providing (skip intake, go straight
to the lane the agent picks up from) and the part that would silently regress if the move were
dropped as redundant.
`workflow_id` is deliberately EXPLICIT here rather than set as the project default: the point
under test is the move, and pinning the workflow keeps the case from depending on default
resolution as well.
REVERT PROOF, measured: delete the post-create move and this fails with
`expected 'ideas' to be 'queued'`; the sibling case above stays green, which is exactly why it
is not sufficient on its own.
*/
it("moves the card off intake onto hold when the workflow separates the two", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-split-lanes" });
const store = h.store();
const split = await store.createWorkflowDefinition({
name: "Split intake and hold",
ir: {
version: "v2",
name: "Split intake and hold",
columns: [
{ id: "ideas", name: "Ideas", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
],
nodes: [
{ id: "start", kind: "start", column: "ideas" },
{ id: "end", kind: "end", column: "queued" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as unknown as WorkflowIr,
});
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-split-lanes",
{ agent_id: agentId, description: "Work past intake", workflow_id: split.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
// No WARNING in the text: a move that fails must say so rather than report a ready card.
expect(result.content[0].text).not.toContain("WARNING");
const { task } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("queued");
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-15:02 (#2843 review — greptile P1, "failed move reports
successful delegation"):
A FAILED LANDING MUST NOT LEAD WITH A SUCCESS CLAIM.
The first version kept "will be picked up by X on their next heartbeat cycle" and appended a
` WARNING: ...`. Assigned-agent selection skips cards outside the workflow's hold lane, so a card
stranded on intake is never dispatched — and the caller, usually another agent, reads the first
sentence and moves on. "Success with a caveat" is how a delegation silently goes nowhere.
WHAT THIS PROVES AND WHAT IT DOES NOT, stated because the distinction matters. It pins the
HANDLING — the branch returns `isError`, never claims pickup, and still surfaces the task id so a
caller can finish by hand. It does NOT prove the failure is reachable in production, and I could
not make it so: `holdColumn` comes from the task's OWN resolved IR, so `moveTask`'s declared-column
check passes by construction. I tried a workflow declaring a `hold` column with no node on it,
expecting rejection; the move SUCCEEDED and the card landed there — `moveTask` accepts any column
the workflow declares, and node reachability does not gate it.
So the store method is stubbed for exactly one call. Stubbing is normally how a test proves only
that a catch block runs, which is why it is worth being explicit: the catch is DEFENSIVE, and what
changed — and what regresses silently — is the shape of the message it produces.
*/
it("reports a failed landing as an ERROR and never claims the agent will pick it up", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-landing-fails" });
const store = h.store();
const split = await store.createWorkflowDefinition({
name: "Split lanes (landing fails)",
ir: {
version: "v2",
name: "Split lanes (landing fails)",
columns: [
{ id: "ideas", name: "Ideas", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
],
nodes: [
{ id: "start", kind: "start", column: "ideas" },
{ id: "end", kind: "end", column: "queued" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as unknown as WorkflowIr,
});
const moveTask = vi.spyOn(store, "moveTask").mockRejectedValueOnce(
new Error("unknown-column: queued"),
);
let result: Awaited<ReturnType<typeof tool.execute>>;
const tool = api.tools.get("fn_delegate_task")!;
try {
result = await tool.execute(
"dt-landing-fails",
{ agent_id: agentId, description: "Landing will fail", workflow_id: split.id },
undefined,
undefined,
makeCtx(tmpDir),
);
} finally {
moveTask.mockRestore();
}
expect(result.isError).toBe(true);
/* The claim that must never survive a failed landing. */
expect(result.content[0].text).not.toContain("will be picked up");
expect(result.content[0].text).toContain("stranded on intake");
/* The card exists either way, so the caller keeps what it needs to finish the job by hand. */
expect(result.details.taskId).toBeTruthy();
expect(result.details.error).toContain("unknown-column");
/* And it really is still on intake — the message is not merely pessimistic. */
const { task } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("ideas");
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-15:45 (#2843 review — coderabbit, "fail the delegation
when the hold lane cannot be resolved"):
THE COLLAPSE WAS REAL; THE PATH THAT REACHES IT IS ALL BUT UNREACHABLE. Both halves stated.
`resolveTaskLifecycleColumns` returns `undefined` when resolution THREW, and a struct whose `hold`
is `undefined` when the board resolved fine and declares no hold lane. Reading it as
`(await ...)?.hold` collapsed the two, so a resolver failure would skip the move and fall into the
SUCCESS response — the same "stranded on intake, reported as ready" outcome the error branch exists
to prevent, by a path that never enters it. Splitting them is correct and costs nothing.
BUT I COULD NOT MAKE THE THROW HAPPEN, and the reason generalises: `resolveWorkflowIrForTask` does
not fail, it SUBSTITUTES the built-in IR. Making the selection read reject (below) is swallowed
there, so the resolve succeeds with the default board, `hold` comes back as `todo`, and on the
built-in board that already equals the card's column — no move, and success is the right answer.
So this asserts the outcome that IS reachable: a degraded resolve that yields the card's own lane
reports success, because nothing went wrong. The `undefined` guard remains as defence, and it is
documented as defence rather than counted as covered.
*/
it("a resolve that DEGRADES to the built-in board still reports success — nothing was stranded", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-resolve-degrades" });
const store = h.store();
const resolve = vi.spyOn(store, "getTaskWorkflowSelectionAsync").mockRejectedValueOnce(
new Error("workflow selection unreadable"),
);
const tool = api.tools.get("fn_delegate_task")!;
let result: Awaited<ReturnType<typeof tool.execute>>;
try {
result = await tool.execute(
"dt-resolve-degrades",
{ agent_id: agentId, description: "Resolve degrades" },
undefined,
undefined,
makeCtx(tmpDir),
);
} finally {
resolve.mockRestore();
}
expect(result.isError).not.toBe(true);
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-22:25 (#2894 review, second round):
NO PICKUP PROMISE ON A DEGRADED RESOLVE. This asserted the confident sentence, which is exactly
the claim the review says must not be made when the landing could not be verified. The card is
still created, assigned and left where it belongs — success — but the text now says the lane is
unconfirmed rather than promising dispatch, and `landingVerified: false` carries the same fact
structurally.
*/
expect(result.content[0].text).not.toContain("will be picked up");
expect(result.content[0].text).toContain("could NOT confirm");
expect(result.details.landingVerified).toBe(false);
/* The lane is still the built-in hold column — unconfirmed is not the same as wrong. */
const { task } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("todo");
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-18:50 (#2843 review — the race, and the fix that removed
it rather than detecting it):
NO TEST FOR A TORN SELECTION SNAPSHOT, because the shape it would test no longer exists.
I wrote one against a version that read the selection on both sides of the resolve and refused when
they disagreed. The version that shipped is better: it drops the second read entirely and judges
the substitution by whether it would MOVE the card, so there is no window for two reads to
disagree. A test asserting "refuses on a torn snapshot" would have been asserting the mechanism I
removed.
The negative below survives, and matters more under either design: a selection read that FAILS must
not fail the delegation.
*/
/*
The paired negative, and the one that keeps the tear check from becoming "refuse whenever the
selection store is flaky": an UNREADABLE selection is unknown, not changed. Failing here would
turn every selection-store hiccup into a failed delegation.
*/
it("still lands the card when a selection read FAILS — unreadable is unknown, not changed", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-selection-unreadable" });
const store = h.store();
const sel = vi.spyOn(store, "getTaskWorkflowSelectionAsync").mockRejectedValue(
new Error("selection store unavailable"),
);
const tool = api.tools.get("fn_delegate_task")!;
let result: Awaited<ReturnType<typeof tool.execute>>;
try {
result = await tool.execute(
"dt-selection-unreadable",
{ agent_id: agentId, description: "Selection unreadable" },
undefined,
undefined,
makeCtx(tmpDir),
);
} finally {
sel.mockRestore();
}
expect(result.isError).not.toBe(true);
/* Same reason as the degraded-resolve case above: unverified landings do not promise pickup. */
expect(result.content[0].text).not.toContain("will be picked up");
expect(result.details.landingVerified).toBe(false);
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-15:55 (#2843 review — greptile, "missing hold reports
successful delegation"):
A BOARD WITH NO HOLD LANE STILL CANNOT DISPATCH THE CARD.
I first treated this as a benign no-op — the board declares no hold lane, there is nowhere to move
the card, staying on intake is correct. Both halves true, conclusion wrong: assigned-agent
selection picks cards OUT OF the hold lane, so on a board that has none, nothing ever dispatches
this card and "will be picked up on their next heartbeat cycle" is false.
Unlike the two cases above, this one needs NO stub. A workflow that simply declares no hold-trait
column is an ordinary board someone can build, which is what makes it the reachable one of the
three and worth the most.
*/
it("reports an ERROR when the workflow declares NO hold lane, instead of promising pickup", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-no-hold" });
const store = h.store();
const noHold = await store.createWorkflowDefinition({
name: "No hold lane",
ir: {
version: "v2",
name: "No hold lane",
columns: [
{ id: "ideas", name: "Ideas", traits: [{ trait: "intake" }] },
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
],
nodes: [
{ id: "start", kind: "start", column: "ideas" },
{ id: "end", kind: "end", column: "building" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as unknown as WorkflowIr,
});
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-no-hold",
{ agent_id: agentId, description: "Nowhere to land", workflow_id: noHold.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).not.toContain("will be picked up");
expect(result.content[0].text).toContain("no hold");
/* The card exists and is assigned; only the dispatch claim is withheld. */
expect(result.details.taskId).toBeTruthy();
expect(result.details.agentId).toBe(agentId);
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-17:45 (#2843 review — greptile P1, third round):
THE INVARIANT: a SUBSTITUTED workflow is not the board's answer, even when its lane exists.
`resolveWorkflowIrForTask` never throws — it substitutes the default coding IR — so a failed
resolution surfaced as the BUILT-IN lanes, `hold: "todo"`. I argued that was harmless because
`todo` would be undeclared on such a board and the move would be rejected. Right about the
mechanism, wrong about the population: a workflow that holds work in `queued` and ALSO declares a
`todo` column for something else gets a move that SUCCEEDS into a lane nothing dispatches from,
and a success message with it.
The fixture is exactly that shape — `ideas` (intake), `queued` (hold), and a plain `todo` that
carries no lifecycle trait — which is why the older cases could not have caught this: theirs
declared no `todo` at all, so the wrong move was rejected for the wrong reason.
The stub targets `getTaskWorkflowSelectionAsync`, the READER, not the writer `createTask` uses,
so creation is untouched and only the post-create resolution sees a workflow id that resolves to
nothing. That is what makes this the first case to reach the "could not be resolved" arm rather
than defending it in a comment.
REVERT PROOF, measured: resolve through `resolveTaskLifecycleColumns` again (no provenance) and
this fails — the tool moves the card to `todo` and reports a successful delegation.
*/
it("does not trust a SUBSTITUTED workflow's lanes even when the board declares that column", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-substituted-workflow" });
const store = h.store();
const declaresTodo = await store.createWorkflowDefinition({
name: "Queued hold, unrelated todo",
ir: {
version: "v2",
name: "Queued hold, unrelated todo",
columns: [
{ id: "ideas", name: "Ideas", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
/* Declared, carries no lifecycle role — the column the built-in fallback would name. */
{ id: "todo", name: "Someday", traits: [] },
],
nodes: [
{ id: "start", kind: "start", column: "ideas" },
{ id: "end", kind: "end", column: "queued" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as unknown as WorkflowIr,
});
/* Reader only: `createTask` writes the selection, it does not read it through this method. */
const selection = vi.spyOn(store, "getTaskWorkflowSelectionAsync")
.mockResolvedValue({ workflowId: "wf-vanished", stepIds: [] });
const tool = api.tools.get("fn_delegate_task")!;
let result: Awaited<ReturnType<typeof tool.execute>>;
try {
result = await tool.execute(
"dt-substituted",
{ agent_id: agentId, description: "Workflow resolves to a substitute", workflow_id: declaresTodo.id },
undefined,
undefined,
makeCtx(tmpDir),
);
} finally {
selection.mockRestore();
}
expect(result.isError).toBe(true);
expect(result.content[0].text).not.toContain("will be picked up");
/* The card must NOT have been moved into the built-in fallback's lane. */
const { task } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).not.toBe("todo");
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:30 (#2843 review — greptile P1, "fallback equality
masks wrong hold"):
EQUALITY WITH A FABRICATED LANE IS NOT EVIDENCE.
The board the review names, and the one every earlier case here misses: `todo` is INTAKE and
`queued` is hold. `createTask` puts the card on `todo`; a degraded lookup fabricates the built-in
`hold: "todo"`; the two match, so the "no move needed" rule accepted it and reported a delegation
for a card assigned-agent dispatch will never select, because the real hold lane is `queued`.
Settled with `params.workflow_id` — caller INPUT, so there is no second snapshot to race. A
caller that named a workflow proves a real one exists, so a substitution proves we failed to read
it, and its hold lane cannot be inferred from the built-in vocabulary.
The neighbouring degraded-resolve cases stay green precisely because they pass NO `workflow_id`:
there, "substituted" and "this project has no resolvable workflow" are the same observation and
the card belongs where it is. That distinction is the whole content of the fix.
REVERT PROOF, measured: drop the `substituted && workflowId` arm and this fails on `isError` —
the tool reports a successful delegation for a card sitting on intake.
*/
it("refuses to claim pickup when the NAMED workflow could not be resolved, even if lanes match", async () => {
const agentId = await seedAgent(tmpDir, { name: "delegate-todo-intake-queued-hold" });
const store = h.store();
const todoIntake = await store.createWorkflowDefinition({
name: "Todo intake, queued hold",
ir: {
version: "v2",
name: "Todo intake, queued hold",
columns: [
/* `todo` is INTAKE here — the id the built-in fallback would call hold. */
{ id: "todo", name: "Inbox", traits: [{ trait: "intake" }] },
{ id: "queued", name: "Queued", traits: [{ trait: "hold" }] },
],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "queued" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
} as unknown as WorkflowIr,
});
/* Reader only — `createTask` writes the selection rather than reading it through this method. */
const selection = vi.spyOn(store, "getTaskWorkflowSelectionAsync")
.mockResolvedValue({ workflowId: "wf-vanished", stepIds: [] });
const tool = api.tools.get("fn_delegate_task")!;
let result: Awaited<ReturnType<typeof tool.execute>>;
try {
result = await tool.execute(
"dt-todo-intake",
{ agent_id: agentId, description: "Intake is called todo here", workflow_id: todoIntake.id },
undefined,
undefined,
makeCtx(tmpDir),
);
} finally {
selection.mockRestore();
}
expect(result.isError).toBe(true);
expect(result.content[0].text).not.toContain("will be picked up");
/* Still on intake, and the message must be about the workflow rather than about a move. */
const { task } = await readTaskWorkflowState(tmpDir, result.details.taskId);
expect(task.column).toBe("todo");
});
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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();
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();
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("surfaces lastError, pauseReason, and recovery counters", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion"), asyncLayer: h.store().getAsyncLayer() });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "diagnostic-agent",
role: "executor",
metadata: {
heartbeatErrorRecovery: { consecutiveAttempts: 1, updatedAt: "2026-07-12T18:20:00.000Z" },
durableErrorRecovery: { attempts: 3, nextRetryAt: "2026-07-12T18:30:00.000Z" },
},
});
await agentStore.updateAgentState(agent.id, "error");
await agentStore.updateAgent(agent.id, {
lastError: "Error: 401 Invalid authentication credentials",
pauseReason: "heartbeat-model-unavailable",
});
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-diagnostics", { id: agent.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("Last Error: Error: 401 Invalid authentication credentials");
expect(result.content[0].text).toContain("Pause Reason: heartbeat-model-unavailable");
expect(result.content[0].text).toContain("Error Recovery: attempts 3, next 2026-07-12T18:30:00.000Z");
});
it("omits empty diagnostic fields for healthy agents", async () => {
const agentId = await seedAgent(tmpDir, { name: "healthy-show-agent" });
const tool = api.tools.get("fn_agent_show")!;
const result = await tool.execute("as-no-diagnostics", { id: agentId }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).not.toContain("Last Error:");
expect(result.content[0].text).not.toContain("Pause Reason:");
expect(result.content[0].text).not.toContain("Error Recovery:");
});
it("shows current task column context for linked agents", async () => {
const store = createStore();
const triageTask = await store.createTask({ description: "Show linked task", column: "triage" });
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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"), asyncLayer: h.store().getAsyncLayer() });
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");
});
});
});