From aa548f81bf67783f4227f3a08a1a6542a44af4f3 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 8 May 2026 11:32:58 -0700 Subject: [PATCH] feat(FN-3278): add direct-mode review tab to task detail modal Merges FN-3278 review tab direct-mode (data, rendering, regression tests, typing fixes, and docs across Steps 2/3/6), FN-3750 mobile keyboard stabilization for mailbox and reply composer, FN-3751 fire-and-forget wake dispatch to prevent request hangs, and FN-3748 workspace aliases for @fusion/* pack Fusion-Task-Id: FN-3278 --- docs/dashboard-guide.md | 1 + docs/task-management.md | 3 +- packages/core/src/types.ts | 16 ++- packages/dashboard/app/api/legacy.ts | 11 ++ .../app/components/TaskReviewTab.css | 25 ++++ .../app/components/TaskReviewTab.tsx | 112 ++++++++++++--- .../TaskDetailModal.rendering.test.tsx | 24 +++- .../__tests__/TaskDetailModal.test-helpers.ts | 5 +- .../__tests__/TaskReviewTab.test.tsx | 54 +++++++- packages/dashboard/app/test/mockApi.ts | 1 + .../src/__tests__/routes-github.test.ts | 50 ++++++- .../__tests__/task-review-routes.test.ts | 81 +++++++++++ .../src/routes/register-git-github.ts | 130 ++++++++++++++++-- 13 files changed, 472 insertions(+), 41 deletions(-) create mode 100644 packages/dashboard/src/routes/__tests__/task-review-routes.test.ts diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4cd1e33d3..4ce528dd5 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -331,6 +331,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - The priority chip in task metadata is now an inline picker: you can change priority directly from the chip without entering full edit mode. - Execution mode now also has a read-mode inline control: a lightning-bolt toggle lets you switch Fast mode on/off from task metadata without opening the full edit form. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. +- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs. ### Logs → Agent Log view diff --git a/docs/task-management.md b/docs/task-management.md index a9dc65b13..3a7af01f3 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -258,7 +258,8 @@ This file is the contract for execution and review. ## Task Comments vs Steering Comments - **Task comments** (`fn task comment`) are general collaboration notes. -- **Review tab feedback** is dedicated actionable review input (PR review data in pull-request mode, reviewer-agent findings in direct mode) used to request same-task revisions. +- **Review tab feedback** is dedicated actionable review input (PR review data in pull-request mode, reviewer-agent findings in direct/non-PR mode) used to request same-task revisions. +- In direct/non-PR auto-merge mode, the Review tab shows parsed reviewer-agent feedback with explicit loading/error/empty states instead of sending users to raw comments or agent logs. - **Steering comments** (`fn task steer`) are execution guidance for the running agent. Steering comments can be injected mid-run into active executor sessions. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cdb543f89..cf4ed94e8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -702,6 +702,8 @@ export interface TaskCommentInput { export type TaskReviewMode = "pull-request" | "direct"; export type TaskReviewSource = "github-pr" | "reviewer-agent"; export type TaskReviewDecision = "approved" | "changes-requested" | "commented" | "pending"; +export type TaskReviewVerdict = "APPROVE" | "REVISE" | "RETHINK" | "UNAVAILABLE"; +export type TaskReviewerType = "plan" | "code"; export type TaskReviewItemStatus = "queued" | "in-progress" | "addressed" | "failed"; export interface TaskReviewItem { @@ -778,6 +780,12 @@ export interface TaskReviewStateItem { state?: string; htmlUrl?: string; isResolved?: boolean; + source?: TaskReviewSource; + reviewType?: TaskReviewerType; + verdict?: TaskReviewVerdict; + step?: number; + summary?: string; + addressingStatus?: TaskReviewItemStatus; } export interface ReviewAddressingRecord { @@ -790,10 +798,16 @@ export interface ReviewAddressingRecord { stale?: boolean; } +export interface ReviewerTaskReviewSummary { + verdict?: TaskReviewVerdict; + reviewType?: TaskReviewerType; + summary?: string; +} + export interface TaskReviewState { source: "pull-request" | "reviewer-agent"; lastRefreshedAt?: string; - summary?: PrTaskReviewSummary; + summary?: PrTaskReviewSummary | ReviewerTaskReviewSummary; items: TaskReviewStateItem[]; addressing: ReviewAddressingRecord[]; } diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b6e4c72c3..14422c47e 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -254,6 +254,12 @@ export interface UpdateTaskReviewRequest { reviewState: TaskDetail["reviewState"] | null; } +export interface TaskReviewResponse { + reviewState: NonNullable; + automationStatus: string | null; + emptyMessage?: string | null; +} + export interface RefreshTaskReviewResponse { reviewState: NonNullable; automationStatus: string | null; @@ -5107,6 +5113,11 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId)); +} + /** Refresh normalized task review data (PR mode or direct mode) */ export function refreshTaskReview(taskId: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), { diff --git a/packages/dashboard/app/components/TaskReviewTab.css b/packages/dashboard/app/components/TaskReviewTab.css index 17518554e..94f7641a3 100644 --- a/packages/dashboard/app/components/TaskReviewTab.css +++ b/packages/dashboard/app/components/TaskReviewTab.css @@ -74,6 +74,23 @@ color: var(--text); } +.task-review-tab__direct-item { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.task-review-tab__body { + margin: 0; + padding: var(--space-sm); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + white-space: pre-wrap; + font-family: var(--font-primary); +} + .task-review-tab__status { color: var(--text-muted); text-transform: capitalize; @@ -120,4 +137,12 @@ .task-review-tab__status { grid-column: 2; } + + .task-review-tab__actions { + width: 100%; + } + + .task-review-tab__actions .btn { + flex: 1; + } } diff --git a/packages/dashboard/app/components/TaskReviewTab.tsx b/packages/dashboard/app/components/TaskReviewTab.tsx index 6ceb177e2..6ae8aa84b 100644 --- a/packages/dashboard/app/components/TaskReviewTab.tsx +++ b/packages/dashboard/app/components/TaskReviewTab.tsx @@ -1,7 +1,7 @@ import "./TaskReviewTab.css"; import type { Task, TaskDetail } from "@fusion/core"; -import { useMemo, useState } from "react"; -import { refreshTaskReview, reviseTaskReviewItems } from "../api"; +import { useEffect, useMemo, useState } from "react"; +import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems } from "../api"; import type { ToastType } from "../hooks/useToast"; interface Props { @@ -11,27 +11,78 @@ interface Props { addToast: (message: string, type?: ToastType) => void; } +const REVIEW_LOAD_ERROR_MESSAGE = "Failed to load review data."; +const DIRECT_MODE_EMPTY_MESSAGE = + "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode."; + function formatTimestamp(value?: string): string { if (!value) return "Never"; return new Date(value).toLocaleString(); } +type ReviewItem = NonNullable["items"][number]; + +function getItemStatus(item: ReviewItem): "queued" | "in-progress" | "addressed" | "failed" { + if ( + item.addressingStatus === "queued" || + item.addressingStatus === "in-progress" || + item.addressingStatus === "addressed" || + item.addressingStatus === "failed" + ) { + return item.addressingStatus; + } + return "queued"; +} + export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Props) { const [selected, setSelected] = useState([]); const [refreshing, setRefreshing] = useState(false); const [revising, setRevising] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const review = task.reviewState; + const [emptyMessage, setEmptyMessage] = useState(null); + const [review, setReview] = useState(task.reviewState ?? null); + const canRevise = selected.length > 0 && !revising; const isPrMode = review?.source === "pull-request"; + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + void fetchTaskReview(task.id, projectId) + .then((result) => { + if (cancelled) return; + setReview(result.reviewState); + setEmptyMessage(result.emptyMessage ?? null); + }) + .catch(() => { + if (cancelled) return; + setError(REVIEW_LOAD_ERROR_MESSAGE); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [task.id, projectId]); + const summaryText = useMemo(() => { if (!review) return "No review feedback captured yet."; - const decision = review.summary?.reviewDecision ?? "REVIEW_REQUIRED"; - return `${decision} · ${review.items.length} review item(s)`; + if (review.source === "pull-request") { + const prSummary = review.summary as { reviewDecision?: string } | undefined; + return `${prSummary?.reviewDecision ?? "REVIEW_REQUIRED"} · ${review.items.length} review item(s)`; + } + const reviewerSummary = review.summary as { summary?: string } | undefined; + return `${reviewerSummary?.summary ?? "reviewer-agent"} · ${review.items.length} review item(s)`; }, [review]); - const decisionLabel = review?.summary?.reviewDecision ?? undefined; + const decisionLabel = !review + ? undefined + : review.source === "pull-request" + ? (review.summary as { reviewDecision?: string } | undefined)?.reviewDecision + : (review.summary as { verdict?: string } | undefined)?.verdict; const toggleSelected = (id: string) => { setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id])); @@ -42,12 +93,12 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop setError(null); setRefreshing(true); const result = await refreshTaskReview(task.id, projectId); + setReview(result.reviewState); onTaskUpdated?.({ ...task, reviewState: result.reviewState } as Task); addToast("Review refreshed", "success"); - } catch (refreshError) { - const message = refreshError instanceof Error ? refreshError.message : "Failed to refresh review"; - setError(message); - addToast(message, "error"); + } catch { + setError(REVIEW_LOAD_ERROR_MESSAGE); + addToast(REVIEW_LOAD_ERROR_MESSAGE, "error"); } finally { setRefreshing(false); } @@ -58,6 +109,7 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop setError(null); setRevising(true); const result = await reviseTaskReviewItems(task.id, selected, projectId); + setReview(result.reviewState); onTaskUpdated?.({ ...result.task, reviewState: result.reviewState } as Task); setSelected([]); addToast("Queued same-task revision", "success"); @@ -85,18 +137,19 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
Last refreshed: {formatTimestamp(review?.lastRefreshedAt)}
- {error ?
{error}
: null} - {!isPrMode ? ( -
GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.
+ {loading ?
Loading review data…
: null} + {!loading && error ?
{error}
: null} + {!loading && !error && !isPrMode && review?.items?.length === 0 ? ( +
{emptyMessage ?? DIRECT_MODE_EMPTY_MESSAGE}
) : null} - {isPrMode && review?.summary?.reviewers?.length ? ( + {isPrMode && review?.summary && "reviewers" in review.summary && review.summary.reviewers?.length ? (
    {review.summary.reviewers.map((reviewer) => (
  • @{reviewer.login} · {reviewer.state}
  • ))}
) : null} - {isPrMode && review?.summary?.blockingReasons?.length ? ( + {isPrMode && review?.summary && "blockingReasons" in review.summary && review.summary.blockingReasons?.length ? (
    {review.summary.blockingReasons.map((reason) =>
  • {reason}
  • )}
@@ -120,9 +173,34 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop ); })} - ) : isPrMode ? ( -
No review items yet.
) : null} + {!loading && !error && !isPrMode && review?.items?.length ? ( +
    + {review.items + .slice() + .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)) + .map((item) => { + const status = getItemStatus(item); + return ( +
  • +
    +
    + reviewer-agent + {item.reviewType ? {item.reviewType} review : null} + {typeof item.step === "number" ? Step {item.step} : null} + {item.verdict ? {item.verdict} : null} + {status} +
    + {item.summary ?

    {item.summary}

    : null} +
    {formatTimestamp(item.createdAt)}
    +
    {item.body}
    +
    +
  • + ); + })} +
+ ) : null} + {isPrMode && !loading && !error && !review?.items?.length ?
No review items yet.
: null} ); } diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index 38bcd633c..474997862 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -16,6 +16,7 @@ import { setupTaskDetailModalHooks, } from "./TaskDetailModal.test-helpers"; import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal"; +import * as dashboardApi from "../../api"; setupTaskDetailModalHooks(); @@ -307,7 +308,7 @@ describe("TaskDetailModal", () => { expect(screen.getByText("Comments")).toBeTruthy(); }); - it("shows non-PR review shell message in Review tab", () => { + it("shows non-PR review shell message in Review tab", async () => { render( { ); fireEvent.click(screen.getByRole("button", { name: "Review" })); - expect(screen.getByText("GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.")).toBeTruthy(); + expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeTruthy(); }); - it("shows PR review decision details in Review tab", () => { + it("shows PR review decision details in Review tab", async () => { + vi.mocked(dashboardApi.fetchTaskReview).mockResolvedValueOnce({ + reviewState: { + source: "pull-request", + summary: { + reviewDecision: "CHANGES_REQUESTED", + reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }], + blockingReasons: ["changes requested review is active"], + checks: [], + }, + items: [], + addressing: [], + }, + automationStatus: null, + emptyMessage: null, + }); render( { ); fireEvent.click(screen.getByRole("button", { name: "Review" })); - expect(screen.getByText("CHANGES_REQUESTED")).toBeTruthy(); + expect(await screen.findByText("CHANGES_REQUESTED")).toBeTruthy(); expect(screen.getByText("changes requested review is active")).toBeTruthy(); }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 41eedab38..3502e9a27 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -35,8 +35,9 @@ vi.mock("../../api", async (importOriginal) => { pauseTask: vi.fn().mockResolvedValue({}), unpauseTask: vi.fn().mockResolvedValue({}), fetchWorkflowResults: vi.fn().mockResolvedValue([]), - refreshTaskReview: vi.fn().mockResolvedValue({ review: undefined, automationStatus: null }), - reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), review: undefined }), + fetchTaskReview: vi.fn().mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode." }), + refreshTaskReview: vi.fn().mockResolvedValue({ reviewState: undefined, automationStatus: null }), + reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), reviewState: undefined }), }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx index 27440e760..e395bfe66 100644 --- a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx @@ -4,11 +4,13 @@ import { TaskReviewTab } from "../TaskReviewTab"; import { makeTask } from "./TaskDetailModal.test-helpers"; const apiMocks = vi.hoisted(() => ({ + fetchTaskReview: vi.fn(), refreshTaskReview: vi.fn(), reviseTaskReviewItems: vi.fn(), })); vi.mock("../../api", () => ({ + fetchTaskReview: apiMocks.fetchTaskReview, refreshTaskReview: apiMocks.refreshTaskReview, reviseTaskReviewItems: apiMocks.reviseTaskReviewItems, })); @@ -18,21 +20,28 @@ describe("TaskReviewTab", () => { vi.clearAllMocks(); }); - it("renders empty state when review is missing", () => { + it("renders direct-mode empty state when no reviewer feedback exists", async () => { + apiMocks.fetchTaskReview.mockResolvedValue({ + reviewState: { source: "reviewer-agent", items: [], addressing: [] }, + automationStatus: null, + emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.", + }); + render(); - expect(screen.getByText("GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.")).toBeInTheDocument(); + expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled(); }); it("calls refresh endpoint", async () => { const task = makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] }, items: [], addressing: [] } }); + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null }); render(); - fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + fireEvent.click(await screen.findByRole("button", { name: "Refresh" })); expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined); }); - it("renders PR decision and status modifiers", () => { + it("renders PR decision and status modifiers", async () => { const task = makeTask({ reviewState: { source: "pull-request", @@ -49,8 +58,9 @@ describe("TaskReviewTab", () => { }, }); + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); render(); - expect(screen.getByText("CHANGES_REQUESTED")).toBeInTheDocument(); + await screen.findByText("CHANGES_REQUESTED"); expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed"); }); @@ -71,14 +81,46 @@ describe("TaskReviewTab", () => { }, }); + apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); apiMocks.reviseTaskReviewItems.mockResolvedValue({ task, reviewState: task.reviewState }); apiMocks.refreshTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null }); render(); - fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(await screen.findByRole("checkbox")); fireEvent.click(screen.getByRole("button", { name: "Request revision" })); expect(apiMocks.reviseTaskReviewItems).toHaveBeenCalledWith(task.id, ["ri-1"], undefined); }); + + it("renders reviewer-agent entries in direct mode", async () => { + const task = makeTask(); + apiMocks.fetchTaskReview.mockResolvedValue({ + reviewState: { + source: "reviewer-agent", + summary: { verdict: "REVISE", reviewType: "code", summary: "Needs fixes" }, + items: [ + { + id: "reviewer-code-1", + body: "## Code Review:\n\n### Verdict:\nREVISE", + author: { login: "reviewer-agent" }, + createdAt: new Date().toISOString(), + reviewType: "code", + verdict: "REVISE", + step: 2, + summary: "code review Step 2: REVISE", + addressingStatus: "in-progress", + }, + ], + addressing: [], + }, + automationStatus: null, + emptyMessage: null, + }); + + render(); + expect(await screen.findByText("reviewer-agent")).toBeInTheDocument(); + expect(screen.getByText("Step 2")).toBeInTheDocument(); + expect(screen.getAllByText("REVISE").length).toBeGreaterThan(0); + }); }); diff --git a/packages/dashboard/app/test/mockApi.ts b/packages/dashboard/app/test/mockApi.ts index 6182df327..bfdcaac32 100644 --- a/packages/dashboard/app/test/mockApi.ts +++ b/packages/dashboard/app/test/mockApi.ts @@ -32,6 +32,7 @@ export const dashboardApiMocks = { fetchAuthStatus: vi.fn(async () => ({ providers: [] })), fetchModels: vi.fn(async () => ({ models: [], favoriteProviders: [], favoriteModels: [] })), fetchTaskDetail: vi.fn(), + fetchTaskReview: vi.fn(), fetchUnreadCount: vi.fn(async () => ({ unreadCount: 0 })), } satisfies Record; diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index b42a51567..f1c8da165 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -2041,15 +2041,63 @@ describe("GET /tasks/:id/review", () => { return app; } - it("returns cached reviewState payload", async () => { + it("returns normalized reviewer-agent payload in direct mode", async () => { (store.getTask as ReturnType).mockResolvedValue({ ...FAKE_TASK_DETAIL, reviewState: { source: "reviewer-agent", items: [], addressing: [] }, + log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "code review Step 2: REVISE" }], }); + (store.getAgentLogs as ReturnType).mockResolvedValue([ + { + timestamp: "2026-05-01T10:00:01.000Z", + taskId: "FN-001", + type: "text", + text: "## Code Review:\n\n### Verdict: REVISE\n\n### Summary\nNeeds null guard\n", + agent: "reviewer", + }, + ]); const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review"); expect(res.status).toBe(200); expect(res.body.reviewState.source).toBe("reviewer-agent"); + expect(res.body.reviewState.items[0].reviewType).toBe("code"); + expect(res.body.reviewState.items[0].verdict).toBe("REVISE"); + }); + + it("returns exact empty payload/message when no reviewer feedback exists", async () => { + (store.getTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + reviewState: { source: "reviewer-agent", items: [], addressing: [] }, + log: [], + }); + (store.getAgentLogs as ReturnType).mockResolvedValue([]); + + const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review"); + expect(res.status).toBe(200); + expect(res.body.reviewState.items).toEqual([]); + expect(res.body.emptyMessage).toBe("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode."); + }); + + it("falls back to task log summary when reviewer output is incomplete", async () => { + (store.getTask as ReturnType).mockResolvedValue({ + ...FAKE_TASK_DETAIL, + reviewState: { source: "reviewer-agent", items: [], addressing: [] }, + log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "plan review Step 1: APPROVE" }], + }); + (store.getAgentLogs as ReturnType).mockResolvedValue([ + { + timestamp: "2026-05-01T10:00:01.000Z", + taskId: "FN-001", + type: "text", + text: "partial stream", + agent: "reviewer", + }, + ]); + + const res = await REQUEST(buildApp(), "GET", "/api/tasks/FN-001/review"); + expect(res.status).toBe(200); + expect(res.body.reviewState.items[0].summary).toBe("plan review Step 1: APPROVE"); + expect(res.body.reviewState.items[0].step).toBe(1); }); it("returns 404 when task is missing", async () => { diff --git a/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts b/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts new file mode 100644 index 000000000..b73bea442 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/task-review-routes.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node + +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import type { TaskStore } from "@fusion/core"; +import { createApiRoutes } from "../../routes.js"; +import { request as REQUEST } from "../../test-request.js"; + +function createMockStore(): TaskStore { + const now = "2026-05-01T00:00:00.000Z"; + return { + getRootDir: vi.fn(() => process.cwd()), + getTask: vi.fn().mockResolvedValue({ + id: "FN-001", + description: "task", + column: "in-review", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: now, + updatedAt: now, + }), + getAgentLogs: vi.fn().mockResolvedValue([]), + } as unknown as TaskStore; +} + +function buildApp(store: TaskStore) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return app; +} + +describe("task review routes", () => { + it("returns normalized reviewer-agent payload for direct mode", async () => { + const store = createMockStore(); + vi.mocked(store.getTask).mockResolvedValue({ + ...(await store.getTask("FN-001")), + log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "code review Step 2: REVISE" }], + } as any); + vi.mocked(store.getAgentLogs).mockResolvedValue([ + { + timestamp: "2026-05-01T10:00:01.000Z", + taskId: "FN-001", + type: "text", + text: "## Code Review:\n\n### Verdict: REVISE\n\n### Summary\nNeeds guard\n", + agent: "reviewer", + }, + ] as any); + + const res = await REQUEST(buildApp(store), "GET", "/api/tasks/FN-001/review"); + expect(res.status).toBe(200); + expect(res.body.reviewState.source).toBe("reviewer-agent"); + expect(res.body.reviewState.items[0].reviewType).toBe("code"); + }); + + it("returns exact empty payload/message when no feedback exists", async () => { + const store = createMockStore(); + const res = await REQUEST(buildApp(store), "GET", "/api/tasks/FN-001/review"); + expect(res.status).toBe(200); + expect(res.body.reviewState.items).toEqual([]); + expect(res.body.emptyMessage).toBe("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode."); + }); + + it("falls back to task-log summary when reviewer output is incomplete", async () => { + const store = createMockStore(); + vi.mocked(store.getTask).mockResolvedValue({ + ...(await store.getTask("FN-001")), + log: [{ timestamp: "2026-05-01T10:00:00.000Z", action: "plan review Step 1: APPROVE" }], + } as any); + vi.mocked(store.getAgentLogs).mockResolvedValue([ + { timestamp: "2026-05-01T10:00:01.000Z", taskId: "FN-001", type: "text", text: "partial", agent: "reviewer" }, + ] as any); + + const res = await REQUEST(buildApp(store), "GET", "/api/tasks/FN-001/review"); + expect(res.status).toBe(200); + expect(res.body.reviewState.items[0].summary).toBe("plan review Step 1: APPROVE"); + expect(res.body.reviewState.items[0].step).toBe(1); + }); +}); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 2a44e6bf9..f893b9bbd 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -1,7 +1,15 @@ import { type NextFunction, type Request, type Response } from "express"; import { isAbsolute } from "node:path"; import { spawn } from "node:child_process"; -import type { BatchStatusEntry, BatchStatusResponse, BatchStatusResult, IssueInfo, PrInfo, TaskStore } from "@fusion/core"; +import type { + BatchStatusEntry, + BatchStatusResponse, + BatchStatusResult, + IssueInfo, + PrInfo, + Task, + TaskStore, +} from "@fusion/core"; import { getCurrentRepo, isGhAuthenticated } from "@fusion/core"; import { ApiError, @@ -75,6 +83,103 @@ export function parseGitHubBadgeUrl(url: string | undefined): { owner: string; r } } +const DIRECT_REVIEW_EMPTY_MESSAGE = + "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode."; + +type CanonicalTaskReviewState = NonNullable; +type CanonicalTaskReviewStateItem = CanonicalTaskReviewState["items"][number]; +type CanonicalTaskReviewVerdict = NonNullable; +type CanonicalTaskReviewerType = NonNullable; + +const REVIEW_BLOCK_RE = /##\s+(Code|Plan)\s+Review:[\s\S]*?(?=\n##\s+(?:Code|Plan)\s+Review:|$)/gi; +const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i; +const REVIEW_SUMMARY_RE = /###\s+Summary\s*\n([\s\S]*?)(?=\n###\s+|$)/i; +const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i; + +function extractDirectReviewItems(task: Task, reviewerText: string): CanonicalTaskReviewStateItem[] { + const fallbackLogs = (task.log ?? []).filter((entry) => REVIEW_STEP_RE.test(entry.action)); + const fallbackByType = new Map(); + for (const entry of fallbackLogs) { + const match = entry.action.match(REVIEW_STEP_RE); + if (!match) continue; + const reviewType = match[1].toLowerCase() === "plan" ? "plan" : "code"; + fallbackByType.set(reviewType, { + step: Number.parseInt(match[2], 10), + verdict: match[3].toUpperCase() as CanonicalTaskReviewVerdict, + timestamp: entry.timestamp, + summary: entry.action, + }); + } + + const items: CanonicalTaskReviewStateItem[] = []; + const blocks = reviewerText.match(REVIEW_BLOCK_RE) ?? []; + for (let index = 0; index < blocks.length; index += 1) { + const block = blocks[index] ?? ""; + const typeMatch = block.match(/##\s+(Code|Plan)\s+Review:/i); + const reviewType: CanonicalTaskReviewerType = typeMatch?.[1]?.toLowerCase() === "plan" ? "plan" : "code"; + const verdict = block.match(REVIEW_VERDICT_RE)?.[1]?.toUpperCase() as CanonicalTaskReviewVerdict | undefined; + const summary = block.match(REVIEW_SUMMARY_RE)?.[1]?.trim() || fallbackByType.get(reviewType)?.summary; + const fallback = fallbackByType.get(reviewType); + items.push({ + id: `reviewer-${reviewType}-${index + 1}`, + body: block.trim(), + author: { login: "reviewer-agent" }, + createdAt: fallback?.timestamp ?? task.updatedAt, + source: "reviewer-agent", + reviewType, + verdict: verdict ?? fallback?.verdict, + step: fallback?.step, + summary, + }); + } + + if (items.length > 0) { + return items; + } + + return fallbackLogs.map((entry, index) => { + const match = entry.action.match(REVIEW_STEP_RE); + const reviewType: CanonicalTaskReviewerType = match?.[1]?.toLowerCase() === "plan" ? "plan" : "code"; + const verdict = match?.[3]?.toUpperCase() as CanonicalTaskReviewVerdict | undefined; + const step = match?.[2] ? Number.parseInt(match[2], 10) : undefined; + return { + id: `reviewer-fallback-${index + 1}`, + body: entry.action, + author: { login: "reviewer-agent" }, + createdAt: entry.timestamp, + source: "reviewer-agent", + reviewType, + verdict, + step, + summary: entry.action, + }; + }); +} + +async function buildDirectReviewState(task: Task, store: TaskStore): Promise { + const agentLogs = await store.getAgentLogs(task.id); + const reviewerText = agentLogs + .filter((entry) => entry.agent === "reviewer" && entry.type === "text") + .map((entry) => entry.text) + .join(""); + const items = extractDirectReviewItems(task, reviewerText); + const newest = [...items].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); + const latest = newest[0]; + return { + source: "reviewer-agent", + lastRefreshedAt: new Date().toISOString(), + summary: latest + ? { + verdict: latest.verdict, + reviewType: latest.reviewType, + summary: latest.summary, + } + : { summary: DIRECT_REVIEW_EMPTY_MESSAGE }, + items: newest, + addressing: task.reviewState?.addressing ?? [], + }; +} + export async function getGitHubRemotes(cwd?: string): Promise { try { const output = await runGitCommand(["remote", "-v"], cwd, 5000); @@ -3128,7 +3233,19 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { try { const { store: scopedStore } = await getProjectContext(req); const task = await scopedStore.getTask(req.params.id); - res.json({ reviewState: task.reviewState ?? null, automationStatus: task.status ?? null }); + const hasPrReview = task.reviewState?.source === "pull-request"; + const reviewState = (hasPrReview + ? task.reviewState + : await buildDirectReviewState(task, scopedStore)) ?? { + source: "reviewer-agent", + items: [], + addressing: [], + }; + res.json({ + reviewState, + automationStatus: task.status ?? null, + emptyMessage: !hasPrReview && reviewState.items.length === 0 ? DIRECT_REVIEW_EMPTY_MESSAGE : null, + }); } catch (err: unknown) { if (err instanceof ApiError) throw err; if ((err as NodeJS.ErrnoException).code === "ENOENT") { @@ -3172,13 +3289,8 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { addressing, }; } else { - reviewState = { - source: task.reviewState?.source ?? "reviewer-agent", - lastRefreshedAt: now, - summary: task.reviewState?.summary, - items: task.reviewState?.items ?? [], - addressing: task.reviewState?.addressing ?? [], - }; + reviewState = await buildDirectReviewState(task, scopedStore); + reviewState.lastRefreshedAt = now; } await scopedStore.updateTask(task.id, { reviewState });