From 35810666bed08887bfa146bd1978260ba93626be Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 4 Aug 2026 22:22:41 -0700 Subject: [PATCH] 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) --- .../dashboard/app/__tests__/api-tasks.test.ts | 14 ++ packages/dashboard/app/api/legacy.ts | 3 + packages/dashboard/app/api/tasks/tasks.ts | 15 ++ .../app/components/TaskDetailModal.tsx | 102 ++++++--- .../TaskDetailModal.rendering.test.tsx | 211 +++++++++++++++--- .../__tests__/TaskDetailModal.test-helpers.ts | 1 + ...r-task-workflow-routes.task-prompt.test.ts | 93 ++++++++ .../routes/register-task-workflow-routes.ts | 17 ++ 8 files changed, 388 insertions(+), 68 deletions(-) create mode 100644 packages/dashboard/src/routes/__tests__/register-task-workflow-routes.task-prompt.test.ts diff --git a/packages/dashboard/app/__tests__/api-tasks.test.ts b/packages/dashboard/app/__tests__/api-tasks.test.ts index 3e1c9d6e51..8030a82117 100644 --- a/packages/dashboard/app/__tests__/api-tasks.test.ts +++ b/packages/dashboard/app/__tests__/api-tasks.test.ts @@ -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; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 34c0359fa5..027b01ec9a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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, diff --git a/packages/dashboard/app/api/tasks/tasks.ts b/packages/dashboard/app/api/tasks/tasks.ts index 66a2bfc06b..b0327a9df3 100644 --- a/packages/dashboard/app/api/tasks/tasks.ts +++ b/packages/dashboard/app/api/tasks/tasks.ts @@ -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 { + return api(withProjectId(`/tasks/${id}/prompt`, projectId)); +} + export async function fetchTaskDetail(id: string, projectId?: string): Promise { const maxAttempts = 2; // 1 initial + 1 retry const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId)); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 21e25ec44a..f33142def7 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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(null); const detailRequestGenerationRef = useRef(0); const detailRequestRef = useRef<{ key: string; promise: Promise } | 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(null); + const workflowMetadataFieldDefsRef = useRef(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: diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index a068bcecfb..880159dc15 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -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(); + + 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(); + + 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((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(); + + const badge = await screen.findByTestId("task-detail-workflow-badge"); + const actions = screen.getByRole("button", { name: "Actions" }); + const initialMoveLabel = document.querySelector(".detail-move-btn")?.getAttribute("aria-label"); + rerender(); + + 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(".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((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(); + 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>) => void = () => undefined; + vi.mocked(dashboardApi.fetchBoardWorkflows).mockImplementationOnce(() => new Promise((resolve) => { + resolveRevalidation = resolve; + })); + render(); + + 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( @@ -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((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((resolve) => { resolveStaleRequest = resolve; })) - .mockImplementationOnce(() => new Promise((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(); 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(); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index f21a98b8c0..ab48630375 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -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([]), diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.task-prompt.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.task-prompt.test.ts new file mode 100644 index 0000000000..a56ef0f2bd --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.task-prompt.test.ts @@ -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 { + 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) { + 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"); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index fe7c1d58ed..9b61a40304 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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 {