diff --git a/packages/cli/src/__tests__/extension-workflow-tools.test.ts b/packages/cli/src/__tests__/extension-workflow-tools.test.ts index 4757a310e2..4ae27132a6 100644 --- a/packages/cli/src/__tests__/extension-workflow-tools.test.ts +++ b/packages/cli/src/__tests__/extension-workflow-tools.test.ts @@ -304,7 +304,18 @@ pgTest("pi extension workflow authoring tools", () => { expect(result.content[0].text).not.toContain("Column: triage"); }); - it("still reports Column: triage for the default builtin:coding workflow (byte-identical regression guard)", async () => { + /* + FNXC:WorkflowResolvedColumns 2026-07-30-20:25: + RE-PINNED, not deleted. This guard existed to catch an unintended change to the column a task + created on `builtin:coding` lands in, and it fired — but the change was INTENDED: U11 merged the + two pre-implementation columns, so the default lineage's intake column is now `todo` (the merged + Planning column, carrying intake+hold+resetOnEntry) and declares no `triage` column at all. + + Re-pinning to the new value keeps the guard doing its job. Deleting it would remove the only check + that this landing column stays stable, and leaving it on `triage` pinned a column the product no + longer has. + */ + it("reports the merged Planning column (`todo`) for the default builtin:coding workflow (byte-identical regression guard)", async () => { const api = createMockApi(); registerExtension(api); const createTask = api.tools.get("fn_task_create")!; @@ -317,7 +328,7 @@ pgTest("pi extension workflow authoring tools", () => { ); expect(result.isError).not.toBe(true); - expect(result.details.column).toBe("triage"); - expect(result.content[0].text).toContain("Column: triage"); + expect(result.details.column).toBe("todo"); + expect(result.content[0].text).toContain("Column: todo"); }); }); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index e130293896..6c6b0390ce 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -3004,10 +3004,19 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { 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("triage", i)} ${"x".repeat(1_000)}`, - description: `Realistic triage task ${String(i).padStart(3, "0")}`, + 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) { @@ -3030,7 +3039,18 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { ); expectSingleBoundedTextBlock(broadResult); expect(broadResult.content.some((block: { type: string }) => block.type === "image")).toBe(false); - expect(broadResult.content[0].text).toContain("Planning (8):"); + /* + 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 [ @@ -3041,9 +3061,9 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { ids: ["FN-001", "FN-002"], }, { - callId: "list-realistic-triage", - params: { column: "triage", limit: 8 }, - header: "Planning (8):", + callId: "list-realistic-in-progress", + params: { column: "in-progress", limit: 8 }, + header: "In Progress (8):", ids: ["FN-013", "FN-014"], }, { @@ -3094,7 +3114,13 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => { 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("Planning (15):"); + /* + 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); diff --git a/packages/cli/src/commands/__tests__/project-lock-retry.test.ts b/packages/cli/src/commands/__tests__/project-lock-retry.test.ts index e34bea313d..5a23287354 100644 --- a/packages/cli/src/commands/__tests__/project-lock-retry.test.ts +++ b/packages/cli/src/commands/__tests__/project-lock-retry.test.ts @@ -66,6 +66,15 @@ vi.mock("@fusion/core", () => ({ })), TaskStore: makeConstructibleMock(makeTaskStore), countRunningAgentTasks: () => 0, + /* + FNXC:CliTests 2026-07-30-20:10: + `getTaskCounts` enriches each row before counting. Both helpers were missing here, so + `resolveWorkflowIrForTask` was undefined and the throw was absorbed by getTaskCounts's fail-soft + catch — which reported zero tasks, the exact symptom this FN-7740 test exists to catch. The test + could not distinguish "lock retry failed" from "the mock is incomplete". + */ + resolveWorkflowIrForTask: vi.fn(async () => undefined), + enrichRunningAgentTaskShape: vi.fn((task: unknown) => task), ensureMemoryFileWithBackend: vi.fn(), readProjectIdentity: vi.fn().mockReturnValue(undefined), writeProjectIdentity: vi.fn(), diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index dfb8922c96..6d0f3142e3 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -42,9 +42,21 @@ const mockTaskStoreInit = vi.fn(); const mockTaskStoreListTasks = vi.fn(); const mockTaskStoreClose = vi.fn(); const mockEnsureMemoryFileWithBackend = vi.fn(); +const mockGetTaskWorkflowSelectionAsync = vi.fn(async () => null); +const mockGetWorkflowDefinition = vi.fn(async () => undefined); -// Mock @fusion/core -vi.mock("@fusion/core", () => ({ +/* +FNXC:CliTests 2026-07-30-23:10 (greptile P2 — mocks bypassed the enrichment): +Spread the REAL module and override only the parts that must be faked (the central/global stores and +the store factory). Previously this factory hand-rolled `countRunningAgentTasks`, `COLUMN_LABELS`, +`resolveWorkflowIrForTask` and `enrichRunningAgentTaskShape`, so the suite exercised a reimplemented +legacy-literal count instead of the workflow-aware enrichment whose absence caused the failures — a +regression in the resolver-to-enrichment integration could leave it green while renamed workflow +columns produced wrong in-flight counts. With the real implementations in place the enrichment path +is genuinely under test, and the label table can no longer disagree with the product. +*/ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal>()), CentralCore: makeConstructibleMock(() => ({ init: mockInit.mockResolvedValue(undefined), close: mockClose.mockResolvedValue(undefined), @@ -73,6 +85,10 @@ vi.mock("@fusion/core", () => ({ taskStore: { init: mockTaskStoreInit, listTasks: mockTaskStoreListTasks, + /* Workflow readers so the REAL `resolveWorkflowIrForTask` can resolve a custom IR; the + renamed-column case below drives these. Default: no selection -> the default IR. */ + getTaskWorkflowSelectionAsync: mockGetTaskWorkflowSelectionAsync, + getWorkflowDefinition: mockGetWorkflowDefinition, }, shutdown: vi.fn(async () => {}), })), @@ -85,23 +101,9 @@ vi.mock("@fusion/core", () => ({ // identity surface so runProjectAdd reaches its forced registration behavior. hasProjectIdentity: vi.fn(() => false), isValidSqliteDatabaseFile: vi.fn(() => false), - countRunningAgentTasks: (tasks: Array<{ column: string; status?: string; paused?: boolean }>) => tasks.filter((task) => ( - task.column === "in-progress" || - (task.column === "triage" && task.status === "planning" && !task.paused) || - (task.column === "in-review" && ["merging", "merging-pr", "merging-fix", "reviewing", "fixing"].includes(String(task.status ?? "")) && !task.paused) - )).length, ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend, readProjectIdentity: vi.fn().mockReturnValue(undefined), writeProjectIdentity: vi.fn(), - COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], - COLUMN_LABELS: { - triage: "Triage", - todo: "To Do", - "in-progress": "In Progress", - "in-review": "In Review", - done: "Done", - archived: "Archived", - }, })); vi.mock("node:readline/promises", () => ({ @@ -127,6 +129,10 @@ describe("project commands", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + /* `clearAllMocks` clears calls but NOT queued resolved values, so the renamed-workflow case + would otherwise leak its custom IR into every later test and mis-count their columns. */ + mockGetTaskWorkflowSelectionAsync.mockReset().mockResolvedValue(null as never); + mockGetWorkflowDefinition.mockReset().mockResolvedValue(undefined as never); consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -350,7 +356,9 @@ describe("project commands", () => { expect(mockTaskStoreListTasks).toHaveBeenCalled(); const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(output).toContain("Total: 3"); - expect(output).toContain("To Do: 2"); + // FNXC:CliTests 2026-07-30-20:10: the real COLUMN_LABELS entry for `todo` is "Todo"; this + // asserted "To Do", a string the product never prints (the old mock invented it). + expect(output).toContain("Todo: 2"); expect(output).toContain("In Progress: 1"); }); @@ -386,6 +394,55 @@ describe("project commands", () => { expect(output).toContain("Completed: 10"); }); + /* + FNXC:CliTests 2026-07-30-23:30 (greptile P2 — the enrichment had no coverage): + The case the previous stubs made impossible. A card sits in a RENAMED wip column (`building`), so + the legacy `column === "in-progress"` fallback answers NO and the count is only correct if the real + `resolveWorkflowIrForTask` -> `enrichRunningAgentTaskShape` -> `countRunningAgentTasks` chain runs + and reads `countsTowardWip` off the resolved IR. + + This is the regression the finding described: with the resolver stubbed to `undefined` and + enrichment stubbed to identity, a break anywhere in that chain left the suite green while + `fn project` reported zero in-flight agents on a renamed board — the number an operator reads to + decide whether the board is busy. + */ + it("runProjectShow counts a RENAMED wip column as In-Flight via real trait enrichment", async () => { + mockGetProject.mockResolvedValue({ + id: "proj-1", + name: "demo", + path: "/tmp/demo", + status: "active", + isolationMode: "in-process", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + mockGetSettings.mockResolvedValue({}); + mockGetProjectHealth.mockResolvedValue(undefined); + + mockGetTaskWorkflowSelectionAsync.mockResolvedValue({ workflowId: "custom:renamed", stepIds: [] } as never); + mockGetWorkflowDefinition.mockResolvedValue({ + ir: { + version: "v2", + id: "custom:renamed", + nodes: [], + edges: [], + columns: [ + { id: "drafting", label: "Drafting", traits: [{ trait: "hold", config: { release: "capacity" } }] }, + { id: "building", label: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, + { id: "shipped", label: "Shipped", traits: [{ trait: "complete" }] }, + ], + }, + } as never); + // `building` is NOT a legacy wip id, so only trait enrichment can classify it as running. + mockTaskStoreListTasks.mockResolvedValue([{ id: "FN-900", column: "building", status: null }]); + + const { runProjectShow } = await import("../project.js"); + await runProjectShow("proj-1"); + + const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("In-Flight Agents: 1"); + }); + it("runProjectShow derives In-Flight Agents from live executors, triage planners, and in-review agents when central health is stale", async () => { mockGetProject.mockResolvedValue({ id: "proj-1",