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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user