FN-7596: regression-test the Coding (Ideas) manual-intake lifecycle end-to-end
Adds cross-layer regression coverage for the manual-intake parking lifecycle (create -> parked -> operator Start promotion -> poll-time todo-discovery), and clarifies the workflow-steps doc to describe the tested lifecycle. - packages/core: covers store create -> moveTask promotion out of the parked intake column - packages/engine: covers triage poll ordering/discovery of the still-unplanned bootstrap-stub card - packages/dashboard: covers TaskCard's Start affordance for parked cards - docs: documents the full regression-tested lifecycle for manual-intake column parking (FN-7596) Files changed: docs/workflow-steps.md | 2 +- .../__tests__/store-create-intake-column.test.ts | 26 ++++ .../app/components/__tests__/TaskCard.test.tsx | 153 +++++++++++++++++++++ packages/engine/src/__tests__/triage.test.ts | 119 +++++++++++++++- 4 files changed, 298 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7596 Fusion-Task-Lineage: 267c3d9a-6181-4ca5-b871-7009c0204372 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -82,7 +82,7 @@ Use this inventory as the documentation map for current workflow behavior:
|
||||
| Routing boundary | Agents may select/change a workflow only for explicit user requests or tasks they created; no-commit markers do not imply Quick fix or any other workflow. | This page, [Selecting workflows](#selecting-workflows); [Agents](./agents.md#interactive-cli-chat). |
|
||||
| Dashboard board/list/graph selection | Board/List/Header/Graph share durable per-project workflow selection; stale saved ids fall back to a valid workflow. Board adds a dashboard-only **All workflows** aggregate and task workflow-name badges; Graph uses **All workflows** for the full active graph. | [Dashboard Guide → Board View](./dashboard-guide.md#board-view), [Graph View](./dashboard-guide.md#graph-view), and [Workflow Selection and Editor](./dashboard-guide.md#workflow-selection-and-editor). |
|
||||
| Create/planning forwarding | Quick-create task creation, Planning Mode, Subtask Breakdown, and the New Task dialog forward the active real workflow id when creating tasks; **All workflows** quick-create chooses a real workflow intake/default column instead of saving a synthetic aggregate id. | [Dashboard Guide → Planning Mode](./dashboard-guide.md#planning-mode). |
|
||||
| Manual-intake column parking | Dashboard create surfaces never send an explicit `column`; the store resolves the landing column from the (selected or project-default) workflow's intake column. A workflow whose intake column sets `autoTriage: false` (e.g. built-in Coding (Ideas)'s `ideas` column) parks new cards there instead of auto-planning them, until an operator promotes the card. | [Dashboard Guide → Create/Planning Forwarding](./dashboard-guide.md#planning-mode). |
|
||||
| Manual-intake column parking | Dashboard create surfaces never send an explicit `column`; the store resolves the landing column from the (selected or project-default) workflow's intake column. A workflow whose intake column sets `autoTriage: false` (e.g. built-in Coding (Ideas)'s `ideas` column) parks new cards there instead of auto-planning them, until an operator promotes the card. The full lifecycle — create → parked → operator "Start" promotion → poll-time todo-discovery of the still-unplanned (bootstrap-stub) card — is regression-tested at the engine (triage poll ordering/discovery), UI (`TaskCard` Start affordance), and store (create → `moveTask` promotion) layers (FN-7596). | [Dashboard Guide → Create/Planning Forwarding](./dashboard-guide.md#planning-mode). |
|
||||
|
||||
### Skill-backed workflow steps
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Task } from "../types.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
import { buildBootstrapPrompt } from "../mesh-task-replication.js";
|
||||
|
||||
/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-04-11:30:
|
||||
@@ -80,4 +81,29 @@ describe("createTask intake-column wiring (Coding (Ideas))", () => {
|
||||
// A direct todo create is NOT an intake column, so it must NOT get the bootstrap stub.
|
||||
expect(prompt).not.toBe(`# ${task.id}\n\n${task.description}\n`);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
|
||||
FN-7596 pins the store-level contract the engine's todo-discovery poll (packages/engine/src/triage.ts eligibleTodoTasks) depends on: promoting a parked Ideas card via moveTask alone must NOT plan it. Only the triage service's bootstrap-prompt discovery loop plans a promoted-but-unplanned todo card; moveTask is a pure column transition.
|
||||
*/
|
||||
it("promotes an Ideas-parked task to todo without planning it (still bootstrap-stub PROMPT.md)", async () => {
|
||||
const store = harness.store();
|
||||
// Custom (non-legacy) column transitions are validated against the workflow IR
|
||||
// only when the workflowColumns compatibility flag is enabled (KTD-1).
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
const task = await store.createTask({
|
||||
description: "ideas lifecycle promotion task",
|
||||
workflowId: "builtin:coding-ideas",
|
||||
});
|
||||
expect(task.column).toBe("ideas");
|
||||
|
||||
const moved = await store.moveTask(task.id, "todo", { moveSource: "user" });
|
||||
expect(moved.column).toBe("todo");
|
||||
|
||||
const prompt = await readFile(
|
||||
join(harness.rootDir(), ".fusion", "tasks", task.id, "PROMPT.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(prompt).toBe(buildBootstrapPrompt(task.id, task.title, task.description));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6411,3 +6411,156 @@ describe("TaskCard custom field badges (U13/KTD-14)", () => {
|
||||
expect(screen.queryByTestId("card-field-badges")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
|
||||
FN-7596 regression-tests the TaskCard "Start" affordance that promotes a Coding (Ideas) manual-intake card. `showStartAction` requires taskColumnFlags.intake and a non-"triage" column; `startTargetColumn` derives the destination from `taskMoveColumns` (first non-intake/non-archived/non-hiddenFromBoard column) rather than a hard-coded "todo" string, per the FNXC comment at its call site.
|
||||
*/
|
||||
describe("TaskCard Start affordance (FN-7596)", () => {
|
||||
it("renders the Start button for a manual-intake column with onMoveTask provided", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("card-start-FN-001")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the Start button when the column is not flagged as an intake", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: false }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the Start button for the triage column even when intake is flagged", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "triage" })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the Start button when no onMoveTask handler is provided", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("card-start-FN-001")).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the Start target from taskMoveColumns instead of a hard-coded 'todo' string", async () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "custom-working-stage" as any }));
|
||||
const addToast = vi.fn();
|
||||
// The intake column itself, plus a non-intake working column that is NOT literally
|
||||
// named "todo", must win over any coincidental fallback — proving derivation, not a hard-coded string.
|
||||
const taskMoveColumns = [
|
||||
{ id: "ideas" as any, label: "Ideas", flags: { intake: true } },
|
||||
{ id: "custom-working-stage" as any, label: "Custom Working Stage", flags: {} },
|
||||
{ id: "todo" as any, label: "Todo", flags: {} },
|
||||
];
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
taskMoveColumns={taskMoveColumns}
|
||||
onOpenDetail={noop}
|
||||
addToast={addToast}
|
||||
onMoveTask={onMoveTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-start-FN-001"));
|
||||
|
||||
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "custom-working-stage"));
|
||||
});
|
||||
|
||||
it("falls back to 'todo' when taskMoveColumns metadata is unavailable", async () => {
|
||||
const onMoveTask = vi.fn().mockResolvedValue(makeTask({ column: "todo" }));
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onMoveTask={onMoveTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-start-FN-001"));
|
||||
|
||||
await waitFor(() => expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo"));
|
||||
});
|
||||
|
||||
it("disables the button and shows the Starting label while the move is in flight, then shows a success toast", async () => {
|
||||
let resolveMove: (task: ReturnType<typeof makeTask>) => void = () => {};
|
||||
const onMoveTask = vi.fn().mockImplementation(
|
||||
() => new Promise((resolve) => { resolveMove = resolve; }),
|
||||
);
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={addToast}
|
||||
onMoveTask={onMoveTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
const startButton = screen.getByTestId("card-start-FN-001");
|
||||
fireEvent.click(startButton);
|
||||
|
||||
await waitFor(() => expect(startButton).toBeDisabled());
|
||||
expect(startButton.textContent).toContain("Starting");
|
||||
|
||||
resolveMove(makeTask({ column: "todo" }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("FN-001"), "success"));
|
||||
await waitFor(() => expect(startButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it("shows an error toast when the Start move fails", async () => {
|
||||
const onMoveTask = vi.fn().mockRejectedValue(new Error("move blocked"));
|
||||
const addToast = vi.fn();
|
||||
|
||||
render(
|
||||
<TaskCard
|
||||
task={makeTask({ column: "ideas" as any })}
|
||||
taskColumnFlags={{ intake: true }}
|
||||
onOpenDetail={noop}
|
||||
addToast={addToast}
|
||||
onMoveTask={onMoveTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("card-start-FN-001"));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith("move blocked", "error"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
|
||||
import { builtinSeamPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
|
||||
import { builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
|
||||
import {
|
||||
TriageProcessor,
|
||||
buildSpecificationPrompt,
|
||||
@@ -2392,6 +2392,123 @@ describe("TriageProcessor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
|
||||
FN-7596 pins the Coding (Ideas) manual-intake lifecycle at the poll-dispatch boundary: an `ideas`-column card must stay parked (never auto-dispatched via `eligibleTriageTasks`, which only matches `column === "triage"`), while a promoted `todo`-column card whose PROMPT.md is still the bootstrap stub must be discovered and specified via `eligibleTodoTasks`'s bootstrap-prompt file check. A `todo` card with a real (non-bootstrap) spec must NOT be re-dispatched, guarding against double-specifying an already-planned card.
|
||||
*/
|
||||
describe("Coding (Ideas) manual-intake discovery (FN-7596)", () => {
|
||||
it("excludes a parked ideas-column task from the poll's specify-dispatch set", async () => {
|
||||
const tasks: Task[] = [
|
||||
createTriageTask({ id: "FN-IDEAS-PARKED", column: "ideas" as any, priority: "urgent" }),
|
||||
];
|
||||
|
||||
const triageStore = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 10,
|
||||
maxTriageConcurrent: 10,
|
||||
pollIntervalMs: 10_000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
}),
|
||||
});
|
||||
const triageProcessor = new TriageProcessor(triageStore, rootDir);
|
||||
const specifySpy = vi
|
||||
.spyOn(triageProcessor, "specifyTask")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
(triageProcessor as any).running = true;
|
||||
await (triageProcessor as any).poll();
|
||||
|
||||
expect(specifySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discovers a promoted todo-column task whose PROMPT.md is still the bootstrap stub", async () => {
|
||||
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-discovery-");
|
||||
const promotedId = "FN-IDEAS-PROMOTED";
|
||||
try {
|
||||
const promotedTask = createTriageTask({
|
||||
id: promotedId,
|
||||
title: "Promoted from Ideas intake",
|
||||
description: "Promoted intake task",
|
||||
column: "todo",
|
||||
priority: "urgent",
|
||||
});
|
||||
await mkdir(join(tempRoot, ".fusion", "tasks", promotedId), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempRoot, ".fusion", "tasks", promotedId, "PROMPT.md"),
|
||||
buildBootstrapPrompt(promotedId, promotedTask.title, promotedTask.description),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const triageStore = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([promotedTask]),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 10,
|
||||
maxTriageConcurrent: 10,
|
||||
pollIntervalMs: 10_000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
}),
|
||||
});
|
||||
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
|
||||
const specifySpy = vi
|
||||
.spyOn(triageProcessor, "specifyTask")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
(triageProcessor as any).running = true;
|
||||
await (triageProcessor as any).poll();
|
||||
|
||||
expect(specifySpy).toHaveBeenCalledTimes(1);
|
||||
expect(specifySpy).toHaveBeenCalledWith(expect.objectContaining({ id: promotedId }));
|
||||
} finally {
|
||||
await cleanupTriageFixtureRoot(tempRoot);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not re-dispatch a todo-column task whose PROMPT.md already carries a real (non-bootstrap) spec", async () => {
|
||||
const tempRoot = await createTriageFixtureRoot("fusion-triage-ideas-planned-");
|
||||
const plannedId = "FN-IDEAS-PLANNED";
|
||||
try {
|
||||
const plannedTask = createTriageTask({
|
||||
id: plannedId,
|
||||
title: "Already planned todo task",
|
||||
description: "Already planned intake task",
|
||||
column: "todo",
|
||||
priority: "urgent",
|
||||
});
|
||||
await mkdir(join(tempRoot, ".fusion", "tasks", plannedId), { recursive: true });
|
||||
await writeFile(
|
||||
join(tempRoot, ".fusion", "tasks", plannedId, "PROMPT.md"),
|
||||
`# Task: ${plannedId} - Already planned todo task\n\n## Mission\n\nThis task carries a real spec, not the bootstrap stub.\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const triageStore = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([plannedTask]),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 10,
|
||||
maxTriageConcurrent: 10,
|
||||
pollIntervalMs: 10_000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
}),
|
||||
});
|
||||
const triageProcessor = new TriageProcessor(triageStore, tempRoot);
|
||||
const specifySpy = vi
|
||||
.spyOn(triageProcessor, "specifyTask")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
(triageProcessor as any).running = true;
|
||||
await (triageProcessor as any).poll();
|
||||
|
||||
expect(specifySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await cleanupTriageFixtureRoot(tempRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("runs deterministic validation without calling the spec reviewer", async () => {
|
||||
const taskId = "FN-001";
|
||||
const testRootDir = await createTriageFixtureRoot("fusion-triage-plan-validation-");
|
||||
|
||||
Reference in New Issue
Block a user