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
This commit is contained in:
Fusion
2026-05-08 11:32:58 -07:00
committed by gsxdsm
parent 6e644e2a77
commit aa548f81bf
13 changed files with 472 additions and 41 deletions

View File

@@ -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[];
}

View File

@@ -254,6 +254,12 @@ export interface UpdateTaskReviewRequest {
reviewState: TaskDetail["reviewState"] | null;
}
export interface TaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
emptyMessage?: string | null;
}
export interface RefreshTaskReviewResponse {
reviewState: NonNullable<TaskDetail["reviewState"]>;
automationStatus: string | null;
@@ -5107,6 +5113,11 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
});
}
/** Fetch normalized task review data (PR mode or direct mode) */
export function fetchTaskReview(taskId: string, projectId?: string): Promise<TaskReviewResponse> {
return api<TaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review`, projectId));
}
/** Refresh normalized task review data (PR mode or direct mode) */
export function refreshTaskReview(taskId: string, projectId?: string): Promise<RefreshTaskReviewResponse> {
return api<RefreshTaskReviewResponse>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/review/refresh`, projectId), {

View File

@@ -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;
}
}

View File

@@ -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<TaskDetail["reviewState"]>["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<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [revising, setRevising] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const review = task.reviewState;
const [emptyMessage, setEmptyMessage] = useState<string | null>(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
</div>
</div>
<div className="task-review-tab__meta">Last refreshed: {formatTimestamp(review?.lastRefreshedAt)}</div>
{error ? <div className="task-review-tab__error">{error}</div> : null}
{!isPrMode ? (
<div className="task-review-tab__empty">GitHub PR review details are only available when auto-merge uses Pull Request mode. Reviewer-agent feedback will appear here in direct mode.</div>
{loading ? <div className="task-review-tab__meta">Loading review data</div> : null}
{!loading && error ? <div className="task-review-tab__error">{error}</div> : null}
{!loading && !error && !isPrMode && review?.items?.length === 0 ? (
<div className="task-review-tab__empty">{emptyMessage ?? DIRECT_MODE_EMPTY_MESSAGE}</div>
) : null}
{isPrMode && review?.summary?.reviewers?.length ? (
{isPrMode && review?.summary && "reviewers" in review.summary && review.summary.reviewers?.length ? (
<ul className="task-review-tab__reviewers">
{review.summary.reviewers.map((reviewer) => (
<li key={`${reviewer.login}-${reviewer.state}`} className="task-review-tab__reviewer">@{reviewer.login} · {reviewer.state}</li>
))}
</ul>
) : null}
{isPrMode && review?.summary?.blockingReasons?.length ? (
{isPrMode && review?.summary && "blockingReasons" in review.summary && review.summary.blockingReasons?.length ? (
<ul className="task-review-tab__blockers">
{review.summary.blockingReasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
@@ -120,9 +173,34 @@ export function TaskReviewTab({ task, projectId, onTaskUpdated, addToast }: Prop
);
})}
</ul>
) : isPrMode ? (
<div className="task-review-tab__empty">No review items yet.</div>
) : null}
{!loading && !error && !isPrMode && review?.items?.length ? (
<ul className="task-review-tab__list">
{review.items
.slice()
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
.map((item) => {
const status = getItemStatus(item);
return (
<li key={item.id} className="task-review-tab__item card">
<div className="task-review-tab__direct-item">
<div className="task-review-tab__summary-wrap">
<span className="task-review-tab__decision">reviewer-agent</span>
{item.reviewType ? <span className="task-review-tab__meta">{item.reviewType} review</span> : null}
{typeof item.step === "number" ? <span className="task-review-tab__meta">Step {item.step}</span> : null}
{item.verdict ? <span className="task-review-tab__decision">{item.verdict}</span> : null}
<span className={`task-review-tab__status task-review-tab__status--${status}`}>{status}</span>
</div>
{item.summary ? <p className="task-review-tab__summary">{item.summary}</p> : null}
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div>
<pre className="task-review-tab__body">{item.body}</pre>
</div>
</li>
);
})}
</ul>
) : null}
{isPrMode && !loading && !error && !review?.items?.length ? <div className="task-review-tab__empty">No review items yet.</div> : null}
</div>
);
}

View File

@@ -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(
<TaskDetailModal
task={makeTask({ reviewState: { source: "reviewer-agent", items: [], addressing: [] } })}
@@ -321,10 +322,25 @@ describe("TaskDetailModal", () => {
);
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(
<TaskDetailModal
task={makeTask({ reviewState: { source: "pull-request", summary: { reviewDecision: "CHANGES_REQUESTED", reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED" }], blockingReasons: ["changes requested review is active"], checks: [] }, items: [], addressing: [] } })}
@@ -338,7 +354,7 @@ describe("TaskDetailModal", () => {
);
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();
});

View File

@@ -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 }),
});
});

View File

@@ -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(<TaskReviewTab task={makeTask({ reviewState: undefined })} addToast={vi.fn()} />);
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
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(<TaskReviewTab task={task} addToast={vi.fn()} />);
expect(await screen.findByText("reviewer-agent")).toBeInTheDocument();
expect(screen.getByText("Step 2")).toBeInTheDocument();
expect(screen.getAllByText("REVISE").length).toBeGreaterThan(0);
});
});

View File

@@ -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<string, AnyFn>;

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
reviewState: { source: "reviewer-agent", items: [], addressing: [] },
log: [],
});
(store.getAgentLogs as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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 () => {

View File

@@ -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);
});
});

View File

@@ -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<Task["reviewState"]>;
type CanonicalTaskReviewStateItem = CanonicalTaskReviewState["items"][number];
type CanonicalTaskReviewVerdict = NonNullable<CanonicalTaskReviewStateItem["verdict"]>;
type CanonicalTaskReviewerType = NonNullable<CanonicalTaskReviewStateItem["reviewType"]>;
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<CanonicalTaskReviewerType, { step?: number; verdict?: CanonicalTaskReviewVerdict; timestamp: string; summary: string }>();
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<CanonicalTaskReviewState> {
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<GitRemote[]> {
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 });