FN-8797: stabilize task detail refreshes

Keep task-detail state and controls mounted while Definition content refreshes.

- Add a prompt-only task endpoint and client request path.
- Preserve task lifecycle, workflow badges, and actions during prompt refreshes and workflow updates.
- Cover prompt refresh stability, late responses, and route behavior.

Files changed:
 packages/dashboard/app/__tests__/api-tasks.test.ts |  14 ++
 packages/dashboard/app/api/legacy.ts               |   3 +
 packages/dashboard/app/api/tasks/tasks.ts          |  15 ++
 .../dashboard/app/components/TaskDetailModal.tsx   | 102 ++++++----
 .../__tests__/TaskDetailModal.rendering.test.tsx   | 211 ++++++++++++++++++---
 .../__tests__/TaskDetailModal.test-helpers.ts      |   1 +
 ...gister-task-workflow-routes.task-prompt.test.ts |  93 +++++++++
 .../src/routes/register-task-workflow-routes.ts    |  17 ++
 8 files changed, 388 insertions(+), 68 deletions(-)

Fusion-Task-Id: FN-8797

Fusion-Task-Lineage: 57d571b4-4a02-4c96-9986-444c525d8405

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 22:22:41 -07:00
parent 26219b8d34
commit 35810666be
8 changed files with 388 additions and 68 deletions

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { API_JSON_HEADERS, API_JSON_HEADERS_NO_ATTRIBUTION } from "../test/apiRequestHeaders";
import {
fetchTaskDetail,
fetchTaskPrompt,
uploadAttachment,
fetchAgentLogsWithMeta,
fetchAiSessions,
@@ -143,6 +144,19 @@ afterEach(() => {
localStorage.removeItem("fn.authToken");
});
describe("fetchTaskPrompt", () => {
const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });
it("requests only the project-scoped narrow prompt endpoint", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "FN-001", prompt: "# Prompt" }));
await expect(fetchTaskPrompt("FN-001", "project-a")).resolves.toEqual({ id: "FN-001", prompt: "# Prompt" });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/prompt?projectId=project-a", expect.objectContaining({ headers: expect.anything() }));
});
});
describe("fetchTaskDetail", () => {
const originalFetch = globalThis.fetch;

View File

@@ -36,6 +36,7 @@ export {
fetchTasks,
fetchArchivedTasks,
fetchTaskDetail,
fetchTaskPrompt,
fetchTaskRuntimeFallback,
checkDuplicateTasks,
createTask,
@@ -49,6 +50,7 @@ import type {
DeleteTaskOptions,
ArchiveTaskOptions,
TaskRuntimeFallbackResponse,
TaskPromptResponse,
UpdateTaskReviewRequest,
TaskReviewResponse,
RefreshTaskReviewResponse,
@@ -65,6 +67,7 @@ export type {
DeleteTaskOptions,
ArchiveTaskOptions,
TaskRuntimeFallbackResponse,
TaskPromptResponse,
UpdateTaskReviewRequest,
TaskReviewResponse,
RefreshTaskReviewResponse,

View File

@@ -65,6 +65,21 @@ export function fetchArchivedTasks(
return api<{ tasks: Task[]; total: number; hasMore: boolean }>(withProjectId(`/tasks/archived${suffix}`, projectId));
}
/** A Definition refresh payload deliberately excludes mutable card state. */
export interface TaskPromptResponse {
id: string;
prompt?: string;
}
/*
FNXC:TaskDetailPlan 2026-08-05-04:05:
Definition polling reads only PROMPT.md. It must not request a TaskDetail because board/SSE/mutation
snapshots exclusively own lifecycle, workflow, and action state while a detail host is mounted.
*/
export function fetchTaskPrompt(id: string, projectId?: string): Promise<TaskPromptResponse> {
return api<TaskPromptResponse>(withProjectId(`/tasks/${id}/prompt`, projectId));
}
export async function fetchTaskDetail(id: string, projectId?: string): Promise<TaskDetail> {
const maxAttempts = 2; // 1 initial + 1 retry
const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId));

View File

@@ -34,7 +34,7 @@ import {
isWipColumnRole,
} from "../utils/columnRoles";
import { resolveEffectiveAutoMerge } from "../../../core/src/merge/task-merge";
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchTaskVerificationRequest, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchTaskPrompt, fetchTaskVerificationRequest, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api";
import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api";
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { WorkflowIcon } from "./WorkflowIcon";
@@ -846,6 +846,12 @@ export function TaskDetailContent({
const [verificationRequest, setVerificationRequest] = useState<TaskVerificationRequest | null>(null);
const detailRequestGenerationRef = useRef(0);
const detailRequestRef = useRef<{ key: string; promise: Promise<TaskDetail> } | null>(null);
/*
FNXC:TaskDetailPlan 2026-08-05-04:26:
A narrow Definition response may beat a slim task's initial full detail response. Keep it
separately so the older full read cannot overwrite its newer prompt on arrival.
*/
const latestPromptResponseRef = useRef<{ key: string; prompt?: string } | null>(null);
/*
FNXC:TaskDetailPlan 2026-08-03-02:24:
@@ -902,7 +908,14 @@ export function TaskDetailContent({
requestTaskDetail(task.id, projectId)
.then((detail) => {
if (!cancelled && detailRequestGenerationRef.current === requestGeneration) {
setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail);
const promptResponse = latestPromptResponseRef.current;
const promptResponseMatchesDetail = promptResponse?.key === `${projectId ?? ""}:${detail.id}`;
const detailWithLatestPrompt = promptResponseMatchesDetail
? { ...detail, prompt: promptResponse.prompt } as TaskDetail
: detail;
setFullDetail((previous) => previous?.id === detail.id
? mergeTaskSnapshot(previous, detailWithLatestPrompt, { fullSnapshot: true })
: detailWithLatestPrompt);
setDetailLoading(false);
}
})
@@ -1209,12 +1222,11 @@ export function TaskDetailContent({
const [showRefineModal, setShowRefineModal] = useState(false);
/*
FNXC:TaskDetailPlan 2026-08-03-02:06:
FNXC:TaskDetailPlan 2026-08-05-04:05:
Definition is the authoritative PROMPT.md view while planning or graph Plan Review may rewrite it.
Refresh on every visible show/re-show, then keep one bounded chain only for planning, replan, or a
running plan-review gate. The request generation prevents a late task/project response from
replacing current detail, and intentionally updates only the authoritative prompt so active edits
retain their local textarea buffer.
Its periodic read is deliberately prompt-only: replacing TaskDetail here rolled queued cards back
to Todo and retriggered workflow metadata. Board/SSE/mutations own card state; this effect updates
only the retained prompt and fences late identity responses without disturbing active edit buffers.
*/
const promptRefreshLifecycleActive = isPromptRefreshLifecycleActive(task);
useEffect(() => {
@@ -1222,37 +1234,32 @@ export function TaskDetailContent({
let cancelled = false;
let inFlight = false;
const requestGeneration = ++detailRequestGenerationRef.current;
const identity = `${projectId ?? ""}:${task.id}`;
const refreshPrompt = () => {
if (inFlight) return;
inFlight = true;
void requestTaskDetail(task.id, projectId)
.then((detail) => {
if (cancelled || detailRequestGenerationRef.current !== requestGeneration || detail.id !== task.id) return;
setFullDetail((previous) => previous ? { ...previous, prompt: detail.prompt } : detail);
setDetailLoading(false);
void fetchTaskPrompt(task.id, projectId)
.then((response) => {
if (cancelled || identity !== `${projectId ?? ""}:${task.id}` || response.id !== task.id) return;
// The narrow contract intentionally distinguishes an absent PROMPT.md from an empty file.
latestPromptResponseRef.current = { key: identity, prompt: response.prompt };
setFullDetail((previous) => previous ? ({ ...previous, prompt: response.prompt } as TaskDetail) : previous);
})
.catch(() => {
// FNXC:TaskDetailPlan 2026-08-03-02:06: retain the last good prompt; a later eligible tick may recover.
// FNXC:TaskDetailPlan 2026-08-05-04:05: retain the last good prompt; a later eligible tick may recover.
})
.finally(() => { inFlight = false; });
};
refreshPrompt();
if (!promptRefreshLifecycleActive) {
return () => {
cancelled = true;
if (detailRequestGenerationRef.current === requestGeneration) detailRequestGenerationRef.current++;
};
}
if (!promptRefreshLifecycleActive) return () => { cancelled = true; };
const timer = window.setInterval(refreshPrompt, PROMPT_REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
if (detailRequestGenerationRef.current === requestGeneration) detailRequestGenerationRef.current++;
};
}, [active, activeTab, projectId, promptRefreshLifecycleActive, requestTaskDetail, task.id]);
}, [active, activeTab, projectId, promptRefreshLifecycleActive, task.id]);
const [prCreateOpen, setPrCreateOpen] = useState(false);
useLayoutEffect(() => {
@@ -1302,6 +1309,9 @@ export function TaskDetailContent({
cannot encode ordered columns, so resolve move metadata independently without replacing
the caller-owned field definitions.
*/
const workflowMetadataIdentityRef = useRef<string | null>(null);
const workflowMetadataFieldDefsRef = useRef<WorkflowFieldDefinition[] | null | undefined>(undefined);
const [workflowMetadataRevision, setWorkflowMetadataRevision] = useState(0);
const [taskWorkflowBadge, setTaskWorkflowBadge] = useState<{ id: string; name: string; icon?: string } | null>(null);
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
// from the board-workflows payload; absent when the workflow declares none,
@@ -1317,20 +1327,39 @@ export function TaskDetailContent({
setCustomFieldValues(task.customFields ?? {});
}, [task.id, task.customFields]);
// Resolve selected-workflow display and move metadata once per task. A supplied fields prop
// avoids a duplicate field lookup, but cannot replace this ordered-column lookup.
/*
FNXC:TaskDetailStateStability 2026-08-05-04:26:
Task workflow selection changes emit `workflow:updated`, not a task-object update. Revalidate
the selected workflow payload on that event so an open unchanged-column detail cannot retain
prior workflow badges or actions; the revision preserves the current metadata while it settles.
*/
useEffect(() => {
if (!active) return;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
return subscribeSse(`/api/events${query}`, {
events: { "workflow:updated": () => setWorkflowMetadataRevision((revision) => revision + 1) },
});
}, [active, projectId]);
// Resolve selected-workflow display and move metadata from the inputs the resolver consumes.
useEffect(() => {
/*
FNXC:WorkflowBadges 2026-06-29-16:48:
Mounted task-detail hosts can swap from one task to another (List split-pane, right dock, floating windows). Clear the previous workflow badge and move metadata before the shared board-workflows lookup resolves so aggregate-board context never shows stale cross-workflow labels or targets.
FNXC:TaskDetailStateStability 2026-08-05-04:05:
Same-task board/SSE object replacements and prompt ticks must not clear workflow badges or
controls. Column is included because it derives action flags; only a true identity/workflow-field
switch clears prior metadata, while column revalidation keeps resolved UI mounted until it settles.
*/
if (workflowFieldDefsProp !== undefined) {
setCustomFieldDefs(workflowFieldDefsProp ?? null);
} else {
setCustomFieldDefs(null);
const metadataIdentity = `${projectId ?? ""}:${task.id}`;
const identityChanged = workflowMetadataIdentityRef.current !== metadataIdentity
|| workflowMetadataFieldDefsRef.current !== workflowFieldDefsProp;
workflowMetadataIdentityRef.current = metadataIdentity;
workflowMetadataFieldDefsRef.current = workflowFieldDefsProp;
if (identityChanged) {
if (workflowFieldDefsProp !== undefined) setCustomFieldDefs(workflowFieldDefsProp ?? null);
else setCustomFieldDefs(null);
setTaskWorkflowBadge(null);
setWorkflowMoveMetadata(null);
}
setTaskWorkflowBadge(null);
setWorkflowMoveMetadata(null);
let cancelled = false;
void fetchBoardWorkflows(projectId)
.then((payload) => {
@@ -1359,16 +1388,15 @@ export function TaskDetailContent({
});
})
.catch(() => {
if (!cancelled) {
// Keep settled same-task metadata visible during transient revalidation failures.
if (!cancelled && identityChanged) {
if (workflowFieldDefsProp === undefined) setCustomFieldDefs(null);
setTaskWorkflowBadge(null);
setWorkflowMoveMetadata(null);
}
});
return () => {
cancelled = true;
};
}, [task, projectId, workflowFieldDefsProp]);
return () => { cancelled = true; };
}, [task.id, task.column, projectId, workflowFieldDefsProp, workflowMetadataRevision]);
/*
FNXC:PlannerOversight 2026-07-04-17:00:

View File

@@ -37,10 +37,12 @@ import {
readDashboardStylesSource,
resetTaskDetailFetchMock,
setupTaskDetailModalHooks,
taskDetailSseSubscriptions,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import * as dashboardApi from "../../api";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
import type { Task } from "@fusion/core";
setupTaskDetailModalHooks();
@@ -90,6 +92,153 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Preserved prompt")).toBeInTheDocument();
});
/*
FNXC:TaskDetailStateStability 2026-08-05-04:05:
Definition ticks must not publish a full task snapshot. Drive repeated planning ticks against
the production detail host and preserve the queued lifecycle and resolved workflow badge node.
*/
it("keeps queued lifecycle and workflow badge continuous across prompt-only ticks", async () => {
vi.useFakeTimers();
try {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true, defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [], fields: [] }], taskWorkflowIds: {},
});
const promptFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
promptFetch.mockResolvedValue({ id: "FN-POLL", prompt: "# Updated definition" });
const fullFetch = vi.mocked(dashboardApi.fetchTaskDetail);
const queued = makeTask({ id: "FN-POLL", column: "in-progress", status: "queued", prompt: "# Initial definition", workflowStepResults: [{ workflowStepId: "plan-review", status: "running", startedAt: "2026-08-05T00:00:00.000Z" }] });
render(<TaskDetailContent embedded active initialTab="definition" task={queued} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
await act(async () => {});
const badge = screen.getByTestId("task-detail-workflow-badge");
const initialPromptRequests = promptFetch.mock.calls.length;
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
for (let tick = 1; tick <= 3; tick++) {
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(fullFetch).not.toHaveBeenCalled();
expect(promptFetch).toHaveBeenCalledTimes(initialPromptRequests + tick);
expect(document.querySelector(".detail-column-badge")).toHaveClass("badge-in-progress");
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
}
expect(screen.getByText("Updated definition")).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
/*
FNXC:TaskDetailStateStability 2026-08-05-05:01:
Done cards use the same Definition timer as queued work. Keep both the resolved workflow badge and
applicable Actions control mounted through every narrow response so the fix cannot merely hide the
queued Todo rollback while completed-task controls still flash.
*/
it("keeps done workflow badge and action controls continuous across prompt-only ticks", async () => {
vi.useFakeTimers();
try {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue({
flagEnabled: true, defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [], fields: [] }], taskWorkflowIds: {},
});
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValue({ id: "FN-DONE-POLL", prompt: "# Refreshed definition" });
const done = makeTask({ id: "FN-DONE-POLL", column: "done", status: "done", prompt: "# Original definition", workflowStepResults: [{ workflowStepId: "plan-review", status: "running", startedAt: "2026-08-05T00:00:00.000Z" }] });
render(<TaskDetailContent embedded active initialTab="definition" task={done} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
await act(async () => {});
const badge = screen.getByTestId("task-detail-workflow-badge");
const actions = screen.getByRole("button", { name: "Actions" });
const initialPromptRequests = vi.mocked(dashboardApi.fetchTaskPrompt).mock.calls.length;
for (let tick = 1; tick <= 3; tick++) {
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(dashboardApi.fetchTaskDetail).not.toHaveBeenCalled();
expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledTimes(initialPromptRequests + tick);
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
expect(screen.getByRole("button", { name: "Actions" })).toBe(actions);
}
} finally {
vi.useRealTimers();
}
});
it("revalidates column actions without clearing same-task workflow metadata", async () => {
const payload = {
flagEnabled: true, defaultWorkflowId: "wf-columns", taskWorkflowIds: {},
workflows: [{ id: "wf-columns", name: "Column workflow", columns: [
{ id: "in-progress", name: "Building", flags: { countsTowardWip: true } },
{ id: "done", name: "Shipped", flags: { complete: true } },
], fields: [] }],
};
let settleColumnMove: (value: typeof payload) => void = () => undefined;
vi.mocked(dashboardApi.fetchBoardWorkflows)
.mockResolvedValueOnce(payload)
.mockImplementationOnce(() => new Promise<typeof payload>((resolve) => { settleColumnMove = resolve; }));
const task = makeTask({ id: "FN-COLUMN-MOVE", column: "in-progress", status: "queued" });
const props = { embedded: true, active: true, initialTab: "definition" as const, onMoveTask: noopMove, onDeleteTask: noopDelete, onMergeTask: noopMerge, onOpenDetail: noopOpenDetail, addToast: noop };
const { rerender } = render(<TaskDetailContent {...props} task={task} />);
const badge = await screen.findByTestId("task-detail-workflow-badge");
const actions = screen.getByRole("button", { name: "Actions" });
const initialMoveLabel = document.querySelector<HTMLButtonElement>(".detail-move-btn")?.getAttribute("aria-label");
rerender(<TaskDetailContent {...props} task={{ ...task, column: "done", status: "done" }} />);
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
expect(screen.getByRole("button", { name: "Actions" })).toBe(actions);
await act(async () => { settleColumnMove(payload); });
await waitFor(() => expect(document.querySelector<HTMLButtonElement>(".detail-move-btn")?.getAttribute("aria-label")).not.toBe(initialMoveLabel));
});
it("keeps a prompt-only response when slim initial detail resolves later", async () => {
let resolveDetail: (detail: TaskDetail) => void = () => undefined;
vi.mocked(dashboardApi.fetchTaskDetail).mockImplementationOnce(() => new Promise<TaskDetail>((resolve) => {
resolveDetail = resolve;
}));
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValueOnce({ id: "FN-slim-prompt", prompt: "# Newer narrow prompt" });
const slimTask = makeTask({ id: "FN-slim-prompt", prompt: undefined }) as Task;
render(<TaskDetailContent embedded active initialTab="definition" task={slimTask} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
await waitFor(() => expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledWith("FN-slim-prompt", undefined));
await act(async () => {
resolveDetail(makeTask({ id: "FN-slim-prompt", prompt: "# Older full prompt" }));
});
expect(await screen.findByText("Newer narrow prompt")).toBeInTheDocument();
expect(screen.queryByText("Older full prompt")).toBeNull();
});
it("revalidates selected workflow metadata after its workflow SSE revision", async () => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true, defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [], fields: [] }],
taskWorkflowIds: { "FN-workflow-revision": "builtin:coding" },
});
let resolveRevalidation: (payload: Awaited<ReturnType<typeof dashboardApi.fetchBoardWorkflows>>) => void = () => undefined;
vi.mocked(dashboardApi.fetchBoardWorkflows).mockImplementationOnce(() => new Promise((resolve) => {
resolveRevalidation = resolve;
}));
render(<TaskDetailContent embedded active task={makeTask({ id: "FN-workflow-revision", column: "todo" })} onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
expect(await screen.findByText("Coding")).toBeInTheDocument();
const badge = screen.getByTestId("task-detail-workflow-badge");
const workflowSubscription = taskDetailSseSubscriptions.find((subscription) => subscription.options.events?.["workflow:updated"]);
expect(workflowSubscription).toBeDefined();
await act(async () => {
workflowSubscription?.options.events?.["workflow:updated"](new MessageEvent("workflow:updated"));
});
expect(screen.getByTestId("task-detail-workflow-badge")).toBe(badge);
expect(screen.getByText("Coding")).toBeInTheDocument();
await act(async () => {
resolveRevalidation({
flagEnabled: true, defaultWorkflowId: "wf-docs",
workflows: [{ id: "wf-docs", name: "Docs", columns: [], fields: [] }],
taskWorkflowIds: { "FN-workflow-revision": "wf-docs" },
});
});
expect(await screen.findByText("Docs")).toBeInTheDocument();
});
describe("workflow timestamp badge", () => {
const workflowPayload = {
flagEnabled: true,
@@ -265,10 +414,10 @@ describe("TaskDetailModal", () => {
summary: "See `packages/dashboard/app/App.tsx:25:3` for context.",
prompt: "# Prompt\n\nInspect `packages/dashboard/app/App.tsx:11`.",
});
vi.mocked(dashboardApi.fetchTaskDetail).mockResolvedValue(makeTask({
...initialDetail,
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValue({
id: initialDetail.id,
prompt: "# Prompt\n\nInspect `packages/dashboard/app/App.tsx:12`.",
}));
});
render(
<FileBrowserProvider openFile={openFile}>
@@ -286,12 +435,12 @@ describe("TaskDetailModal", () => {
);
/*
FNXC:DashboardTests 2026-08-04-15:05:
Definition refresh replaces its markdown tree. Query only after it settles so
this integration test clicks the live prompt link, then assert the separately
rendered completed-summary surface retains the same FileBrowser contract.
FNXC:DashboardTests 2026-08-05-04:05:
Definition refresh updates only its prompt tree. Query after the narrow response settles so
this integration test clicks the live prompt link, then assert the separately rendered
completed-summary surface retains the same FileBrowser contract.
*/
await waitFor(() => expect(dashboardApi.fetchTaskDetail).toHaveBeenCalledWith("FN-099", undefined));
await waitFor(() => expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledWith("FN-099", undefined));
const promptLink = await screen.findByRole("button", { name: "packages/dashboard/app/App.tsx:12" });
expect(screen.queryByRole("button", { name: "packages/dashboard/app/App.tsx:11" })).toBeNull();
expect(promptLink.closest("code")?.querySelector("button.file-path-link")).toBe(promptLink);
@@ -2438,6 +2587,8 @@ describe("TaskDetailModal", () => {
describe("optimistic opening with Task", () => {
beforeEach(async () => {
await resetTaskDetailFetchMock();
vi.mocked(dashboardApi.fetchTaskPrompt).mockReset();
vi.mocked(dashboardApi.fetchTaskPrompt).mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" });
});
it("restores a resolved detail Promise after an override is reset", async () => {
@@ -2467,7 +2618,7 @@ describe("TaskDetailModal", () => {
/>,
);
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-099", undefined));
await waitFor(() => expect(dashboardApi.fetchTaskPrompt).toHaveBeenCalledWith("FN-099", undefined));
await waitFor(() => expect(screen.queryByText("Restored default prompt")).toBeNull());
});
@@ -2562,9 +2713,9 @@ describe("TaskDetailModal", () => {
});
});
it("uses one Definition refresh request when prop is already a TaskDetail with prompt", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
it("uses a prompt-only Definition refresh when prop is already a TaskDetail with prompt", async () => {
const mockPromptFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
const mockFetch = vi.mocked(dashboardApi.fetchTaskDetail);
const detail: TaskDetail = {
id: "FN-202",
@@ -2592,15 +2743,13 @@ describe("TaskDetailModal", () => {
/>,
);
// A full detail skips the slim initial load but Definition still refreshes
// its authoritative prompt exactly once when shown.
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-202", undefined));
expect(mockFetch).toHaveBeenCalledTimes(1);
// A full detail skips the full client; Definition reads only its prompt.
await waitFor(() => expect(mockPromptFetch).toHaveBeenCalledWith("FN-202", undefined));
expect(mockFetch).not.toHaveBeenCalled();
});
it("retains the last good prompt when Definition refresh rejects", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
const detail = makeTask({ id: "FN-202-rejected", prompt: "# Last good prompt" });
mockFetch.mockRejectedValueOnce(new Error("refresh failed"));
@@ -2632,13 +2781,13 @@ describe("TaskDetailModal", () => {
it("defers embedded refresh while inactive, deduplicates pending interval work, and resumes on re-show", async () => {
vi.useFakeTimers();
try {
const mockFetch = vi.mocked(dashboardApi.fetchTaskDetail);
let resolveFirstRequest: (detail: TaskDetail) => void = () => undefined;
mockFetch.mockImplementationOnce(() => new Promise<TaskDetail>((resolve) => {
const mockFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
let resolveFirstRequest: (detail: { id: string; prompt?: string }) => void = () => undefined;
mockFetch.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => {
resolveFirstRequest = resolve;
}));
mockFetch.mockResolvedValueOnce(makeTask({ id: "FN-lifecycle", prompt: "# Interval refresh" }));
mockFetch.mockResolvedValue(makeTask({ id: "FN-lifecycle", prompt: "# Re-shown refresh" }));
mockFetch.mockResolvedValueOnce({ id: "FN-lifecycle", prompt: "# Interval refresh" });
mockFetch.mockResolvedValue({ id: "FN-lifecycle", prompt: "# Re-shown refresh" });
const props = {
embedded: true,
initialTab: "definition" as const,
@@ -2660,7 +2809,7 @@ describe("TaskDetailModal", () => {
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(mockFetch).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirstRequest(makeTask({ id: "FN-lifecycle", prompt: "# First refresh" })); });
await act(async () => { resolveFirstRequest({ id: "FN-lifecycle", prompt: "# First refresh" }); });
expect(screen.getByText("First refresh")).toBeInTheDocument();
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
@@ -2679,12 +2828,12 @@ describe("TaskDetailModal", () => {
});
it("fences a cancelled embedded refresh after a visible task switch", async () => {
const mockFetch = vi.mocked(dashboardApi.fetchTaskDetail);
let resolveStaleRequest: (detail: TaskDetail) => void = () => undefined;
let resolveCurrentRequest: (detail: TaskDetail) => void = () => undefined;
const mockFetch = vi.mocked(dashboardApi.fetchTaskPrompt);
let resolveStaleRequest: (detail: { id: string; prompt?: string }) => void = () => undefined;
let resolveCurrentRequest: (detail: { id: string; prompt?: string }) => void = () => undefined;
mockFetch
.mockImplementationOnce(() => new Promise<TaskDetail>((resolve) => { resolveStaleRequest = resolve; }))
.mockImplementationOnce(() => new Promise<TaskDetail>((resolve) => { resolveCurrentRequest = resolve; }));
.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => { resolveStaleRequest = resolve; }))
.mockImplementationOnce(() => new Promise<{ id: string; prompt?: string }>((resolve) => { resolveCurrentRequest = resolve; }));
const sharedProps = {
embedded: true,
initialTab: "definition" as const,
@@ -2704,10 +2853,10 @@ describe("TaskDetailModal", () => {
rerender(<TaskDetailContent {...sharedProps} task={currentTask} active />);
await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-current-detail", undefined));
await act(async () => { resolveCurrentRequest(makeTask({ id: "FN-current-detail", prompt: "# Current response" })); });
await act(async () => { resolveCurrentRequest({ id: "FN-current-detail", prompt: "# Current response" }); });
expect(await screen.findByText("Current response")).toBeInTheDocument();
await act(async () => { resolveStaleRequest(makeTask({ id: "FN-stale-detail", prompt: "# Stale response" })); });
await act(async () => { resolveStaleRequest({ id: "FN-stale-detail", prompt: "# Stale response" }); });
expect(screen.queryByText("Stale response")).toBeNull();
expect(screen.getByText("Current response")).toBeInTheDocument();
});

View File

@@ -32,6 +32,7 @@ vi.mock("../../api", async (importOriginal) => {
repairOverlapBlocker: vi.fn().mockResolvedValue({ repaired: true, statusCleared: false, reason: "repaired", message: "Repaired", task: makeTask() }),
summarizeTitle: vi.fn().mockResolvedValue("Generated Title"),
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
fetchTaskPrompt: vi.fn().mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" }),
// FNXC:DashboardTests 2026-07-19-01:20: FN-8296 TaskDetail polls verification request status.
fetchTaskVerificationRequest: vi.fn().mockResolvedValue(null),
fetchAgentLogs: vi.fn().mockResolvedValue([]),

View File

@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import express from "express";
import type { Task, TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
function task(overrides: Partial<Task> = {}): Task {
return {
id: "FN-PROMPT", title: "Prompt task", description: "Detail must stay private", column: "queued",
status: "queued", dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: "2026-08-05T00:00:00.000Z", updatedAt: "2026-08-05T00:00:00.000Z", prompt: "# Current definition",
...overrides,
} as Task;
}
function scopedApp(stores: Record<string, TaskStore>) {
const engineManager = {
getEngine: vi.fn((projectId: string) => ({ getTaskStore: () => stores[projectId] })),
onProjectAccessed: vi.fn(),
};
const app = express();
app.use("/api", createApiRoutes({
getRootDir: () => process.cwd(),
getProjectScopedPluginMcpServers: async () => [],
getSettingsFast: async () => ({}),
getPluginStore: () => ({}),
} as unknown as TaskStore, { engineManager } as never));
return { app, engineManager };
}
describe("task prompt route registration", () => {
it("matches the literal prompt route before task detail and returns its narrow shape", async () => {
const getTask = vi.fn(async (id: string) => {
if (id !== "FN-PROMPT") throw Object.assign(new Error("missing"), { code: "ENOENT" });
return task();
});
const store = {
getRootDir: vi.fn(() => process.cwd()), getProjectScopedPluginMcpServers: vi.fn(async () => []),
getSettingsFast: vi.fn(async () => ({})), getTask,
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
const response = await REQUEST(app, "GET", "/api/tasks/FN-PROMPT/prompt");
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: "FN-PROMPT", prompt: "# Current definition" });
expect(Object.keys(response.body)).toEqual(["id", "prompt"]);
expect(getTask).toHaveBeenCalledWith("FN-PROMPT");
expect((await REQUEST(app, "GET", "/api/tasks/unknown/prompt")).status).toBe(404);
});
it("preserves empty and absent prompts without serializing lifecycle fields", async () => {
const store = {
getRootDir: vi.fn(() => process.cwd()), getProjectScopedPluginMcpServers: vi.fn(async () => []),
getSettingsFast: vi.fn(async () => ({})), getTask: vi.fn(async (id: string) => task({ prompt: id === "FN-EMPTY" ? "" : undefined })),
} as unknown as TaskStore;
const app = express();
app.use("/api", createApiRoutes(store));
expect(await REQUEST(app, "GET", "/api/tasks/FN-EMPTY/prompt")).toMatchObject({ status: 200, body: { id: "FN-PROMPT", prompt: "" } });
expect(await REQUEST(app, "GET", "/api/tasks/FN-PROMPT/prompt")).toMatchObject({ status: 200, body: { id: "FN-PROMPT" } });
});
it("uses the selected project store and maps task misses and ordinary errors", async () => {
const projectA = {
getPluginStore: () => ({ init: async () => {} }),
getProjectScopedPluginMcpServers: async () => [],
getRootDir: () => process.cwd(),
getTask: vi.fn(async (id: string) => id === "FN-PROMPT" ? task({ id }) : Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" }))),
} as unknown as TaskStore;
const projectB = {
getPluginStore: () => ({ init: async () => {} }),
getProjectScopedPluginMcpServers: async () => [],
getRootDir: () => process.cwd(),
getTask: vi.fn(async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }),
} as unknown as TaskStore;
const broken = {
getPluginStore: () => ({ init: async () => {} }),
getProjectScopedPluginMcpServers: async () => [],
getRootDir: () => process.cwd(),
getTask: vi.fn(async () => { throw new Error("database offline"); }),
} as unknown as TaskStore;
const { app, engineManager } = scopedApp({ "project-a": projectA, "project-b": projectB, broken });
expect((await REQUEST(app, "GET", "/api/tasks/FN-PROMPT/prompt?projectId=project-a")).body).toEqual({ id: "FN-PROMPT", prompt: "# Current definition" });
expect((await REQUEST(app, "GET", "/api/tasks/FN-PROMPT/prompt?projectId=project-b")).status).toBe(404);
expect((await REQUEST(app, "GET", "/api/tasks/unknown/prompt?projectId=project-a")).status).toBe(404);
expect((await REQUEST(app, "GET", "/api/tasks/FN-PROMPT/prompt?projectId=broken")).status).toBe(500);
expect(engineManager.getEngine).toHaveBeenCalledWith("project-a");
expect(projectA.getTask).toHaveBeenCalledWith("FN-PROMPT");
expect(projectB.getTask).toHaveBeenCalledWith("FN-PROMPT");
});
});

View File

@@ -3947,6 +3947,23 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
/*
FNXC:TaskDetailPlan 2026-08-05-04:05:
This literal route precedes /tasks/:id so Express cannot capture `prompt` as a task id.
Definition polling gets only identity plus PROMPT.md and cannot replace lifecycle or workflow state.
*/
router.get("/tasks/:id/prompt", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
res.json(task.prompt === undefined ? { id: task.id } : { id: task.id, prompt: task.prompt });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
if (isTaskLookupMiss(err)) throw notFound(`Task ${req.params.id} not found`);
rethrowAsApiError(err, "Internal server error");
}
});
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {