feat(FN-3783): add review panel plugin for fusion reports with type definit

The merge delivers the **Reports Review Panel** (FN-3783) as a multi-step plugin feature — defining review panel types, implementing orchestration logic, and wiring them through the fusion-plugin-reports entry point — alongside a collection of complementary improvements: section-aware prompt compact

Fusion-Task-Id: FN-3783
This commit is contained in:
Fusion
2026-05-09 12:27:48 -07:00
committed by gsxdsm
parent 44252e8c8b
commit cfa4896d38
8 changed files with 677 additions and 32 deletions

View File

@@ -2,4 +2,88 @@
Generates HTML system activity reports with multi-agent review.
> Scaffold-only package delivered in FN-3778. Runtime scheduling, report generation, review orchestration, publishing, and dashboard view behavior are implemented in follow-up tasks FN-3779 through FN-3787.
## Review Panel
The plugin exposes `runReviewPanel()` / `runGeneratedReportReview()` to fan out a generated report draft to multiple reviewer agents in parallel.
### Panel member settings shape
Each reviewer uses this contract:
```ts
{
id: string;
name: string;
perspective: string;
promptTemplateId?: string;
provider?: string;
modelId?: string;
}
```
- `perspective` is appended to the reviewer system prompt.
- `promptTemplateId` selects a template from `settings.reviewPromptTemplates[templateId]` when present.
- `provider` + `modelId` optionally override model selection per reviewer.
### Prompt template contract
`runReviewPanel` resolves reviewer templates in this order:
1. `settings.reviewPromptTemplates[promptTemplateId ?? id]`
2. `settings.reviewPrompt`
3. Built-in fallback (`DEFAULT_REVIEW_PROMPT`)
This is the temporary compatibility contract until FN-3782 lands shared review-template helpers.
### Individual review shape
```ts
{
memberId: string;
memberName: string;
perspective: string;
verdict: "approve" | "revise" | "reject";
summary: string;
highlights: string[];
lowlights: string[];
suggestions: string[];
rawText: string;
durationMs: number;
}
```
### Combined review shape
```ts
{
overallVerdict: "approve" | "revise" | "reject";
consensusSummary: string;
mergedHighlights: string[];
mergedLowlights: string[];
mergedSuggestions: string[];
individual: IndividualReview[];
failures: ReviewFailure[];
}
```
Aggregation is deterministic:
- verdict precedence: `approve < revise < reject`
- merged arrays are case-insensitive de-duped, first-seen order, max 25 items each
- consensus summary is generated locally from reviewer summaries (no second AI call)
### Timeout and failure semantics
- Each reviewer has a hard timeout (`120_000ms`).
- A single reviewer failure never aborts the full panel.
- Failures are returned as:
```ts
{
memberId: string;
reason: "timeout" | "parse_error" | "session_unavailable" | "exception";
message: string;
}
```
- If all reviewers fail, combined verdict is `reject` with an explicit consensus summary describing panel failure.

View File

@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CreateAiSessionFactory, PluginContext } from "@fusion/core";
import { __setCreateAiSessionFactory, combineReviews, runReviewPanel } from "../review-panel.js";
import type { ReviewPanelMember } from "../review-types.js";
import { ReviewPanelError } from "../review-types.js";
function createContext(createAiSession?: CreateAiSessionFactory): PluginContext {
return {
pluginId: "fusion-plugin-reports",
taskStore: {} as PluginContext["taskStore"],
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession,
};
}
const panel: ReviewPanelMember[] = [
{ id: "qa", name: "QA", perspective: "Quality" },
{ id: "ops", name: "Ops", perspective: "Operations" },
{ id: "pm", name: "PM", perspective: "Product" },
];
afterEach(() => {
__setCreateAiSessionFactory(undefined);
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("runReviewPanel", () => {
it("returns individual and combined reviews for a 3-member panel", async () => {
const createAiSession = vi.fn(async ({ systemPrompt }) => ({
session: {
prompt: vi.fn(async () => {}),
state: {
messages: [{ role: "assistant", content: JSON.stringify({ verdict: "approve", summary: systemPrompt, highlights: ["A"], lowlights: ["B"], suggestions: ["C"] }) }],
},
dispose: vi.fn(),
},
}));
const result = await runReviewPanel({
reportDraft: "draft",
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
panel,
cwd: "/tmp",
}, createContext(createAiSession));
expect(result.individual).toHaveLength(3);
expect(result.failures).toEqual([]);
expect(result.overallVerdict).toBe("approve");
expect(result.consensusSummary).toContain("Quality:");
});
it("deduplicates merged arrays in first-seen order", () => {
const merged = combineReviews([
{ memberId: "a", memberName: "A", perspective: "P1", verdict: "approve", summary: "ok", highlights: [" Alpha ", "Beta"], lowlights: ["Lag"], suggestions: ["Fix docs"], rawText: "", durationMs: 1 },
{ memberId: "b", memberName: "B", perspective: "P2", verdict: "approve", summary: "ok", highlights: ["alpha", "Gamma"], lowlights: [" lag "], suggestions: ["fix docs", "Add tests"], rawText: "", durationMs: 1 },
], []);
expect(merged.mergedHighlights).toEqual(["Alpha", "Beta", "Gamma"]);
expect(merged.mergedLowlights).toEqual(["Lag"]);
expect(merged.mergedSuggestions).toEqual(["Fix docs", "Add tests"]);
});
it("applies verdict precedence", () => {
const revise = combineReviews([
{ memberId: "a", memberName: "A", perspective: "P1", verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
{ memberId: "b", memberName: "B", perspective: "P2", verdict: "revise", summary: "rev", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
], []);
expect(revise.overallVerdict).toBe("revise");
const reject = combineReviews([
{ memberId: "a", memberName: "A", perspective: "P1", verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
{ memberId: "b", memberName: "B", perspective: "P2", verdict: "reject", summary: "no", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
], []);
expect(reject.overallVerdict).toBe("reject");
});
it("records timeout failure without aborting other reviewers", async () => {
vi.useFakeTimers();
const createAiSession = vi.fn(async ({ systemPrompt }) => {
if (systemPrompt.includes("Operations")) {
return {
session: {
prompt: vi.fn(async () => new Promise(() => {})),
state: { messages: [] },
dispose: vi.fn(),
},
};
}
return {
session: {
prompt: vi.fn(async () => {}),
state: { messages: [{ role: "assistant", content: JSON.stringify({ verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [] }) }] },
dispose: vi.fn(),
},
};
});
const promise = runReviewPanel({
reportDraft: "draft",
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
panel,
cwd: "/tmp",
}, createContext(createAiSession));
await vi.advanceTimersByTimeAsync(120_000);
const result = await promise;
expect(result.individual).toHaveLength(2);
expect(result.failures).toHaveLength(1);
expect(result.failures[0]?.reason).toBe("timeout");
});
it("retries malformed JSON once then reports parse_error", async () => {
const prompt = vi.fn(async () => {});
const createAiSession = vi.fn(async () => ({
session: {
prompt,
state: { messages: [{ role: "assistant", content: "not-json" }] },
dispose: vi.fn(),
},
}));
const result = await runReviewPanel({
reportDraft: "draft",
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
panel: [panel[0]],
cwd: "/tmp",
}, createContext(createAiSession));
expect(prompt).toHaveBeenCalledTimes(2);
expect(result.individual).toEqual([]);
expect(result.failures).toHaveLength(1);
expect(result.failures[0]?.reason).toBe("parse_error");
});
it("throws ReviewPanelError when createAiSession is unavailable", async () => {
await expect(runReviewPanel({
reportDraft: "draft",
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
panel,
cwd: "/tmp",
}, createContext(undefined))).rejects.toBeInstanceOf(ReviewPanelError);
});
it("forwards provider/modelId to createAiSession per member", async () => {
const createAiSession = vi.fn(async () => ({
session: {
prompt: vi.fn(async () => {}),
state: { messages: [{ role: "assistant", content: JSON.stringify({ verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [] }) }] },
dispose: vi.fn(),
},
}));
await runReviewPanel({
reportDraft: "draft",
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
panel: [{ id: "m1", name: "M1", perspective: "P", provider: "anthropic", modelId: "claude" }],
cwd: "/tmp",
}, createContext(createAiSession));
expect(createAiSession).toHaveBeenCalledWith(expect.objectContaining({ defaultProvider: "anthropic", defaultModelId: "claude" }));
});
});

View File

@@ -1,4 +1,7 @@
import type { PluginContext } from "@fusion/core";
import { definePlugin } from "@fusion/plugin-sdk";
import { runReviewPanel } from "./review-panel.js";
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
import { settingsSchema } from "./settings.js";
const plugin = definePlugin({
@@ -15,6 +18,24 @@ const plugin = definePlugin({
hooks: {},
});
export interface RunGeneratedReportReviewInput {
reportDraft: string;
reportMetadata: RunReviewPanelInput["reportMetadata"];
panel: ReviewPanelMember[];
cwd: string;
}
export async function runGeneratedReportReview(input: RunGeneratedReportReviewInput, ctx: PluginContext): Promise<CombinedReview> {
return runReviewPanel({
reportDraft: input.reportDraft,
reportMetadata: input.reportMetadata,
panel: input.panel,
cwd: input.cwd,
}, ctx);
}
export default plugin;
export * from "./settings.js";
export * from "./review-types.js";
export * from "./review-panel.js";

View File

@@ -0,0 +1,294 @@
import type { CreateAiSessionFactory, PluginContext } from "@fusion/core";
import { DEFAULT_REVIEW_PROMPT } from "./settings.js";
import type {
CombinedReview,
IndividualReview,
ReviewFailure,
ReviewPanelMember,
ReviewVerdict,
RunReviewPanelInput,
} from "./review-types.js";
import { ReviewPanelError, ReviewParseError, ReviewTimeoutError } from "./review-types.js";
let injectedCreateAiSession: CreateAiSessionFactory | undefined;
const MAX_PARSE_RETRIES = 1;
const MAX_MERGED_ITEMS = 25;
export const REVIEW_TIMEOUT_MS = 120_000;
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
function pickFactory(ctx: PluginContext): CreateAiSessionFactory | undefined {
return injectedCreateAiSession ?? ctx.createAiSession;
}
function getReviewPromptTemplate(reviewerId: string, settings: Record<string, unknown>): string {
const templates = settings.reviewPromptTemplates;
if (templates && typeof templates === "object" && !Array.isArray(templates)) {
const candidate = (templates as Record<string, unknown>)[reviewerId];
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
const reviewPrompt = settings.reviewPrompt;
if (typeof reviewPrompt === "string" && reviewPrompt.trim()) return reviewPrompt.trim();
return DEFAULT_REVIEW_PROMPT;
}
function buildSystemPrompt(member: ReviewPanelMember, settings: Record<string, unknown>): string {
const templateId = member.promptTemplateId ?? member.id;
const template = getReviewPromptTemplate(templateId, settings);
return `${template}\n\nReviewer perspective: ${member.perspective}`;
}
function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null;
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
const source = codeBlockMatch?.[1]?.trim() ?? text.trim();
const startIndex = source.indexOf("{");
if (startIndex < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = startIndex; index < source.length; index++) {
const char = source[index];
if (inString) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') inString = false;
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") depth++;
if (char === "}") {
depth--;
if (depth === 0) return source.slice(startIndex, index + 1).trim();
}
}
return source.slice(startIndex).trim();
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
}
function parseReviewPayload(text: string): Omit<IndividualReview, "memberId" | "memberName" | "perspective" | "durationMs" | "rawText"> {
const candidate = extractJsonCandidate(text);
if (!candidate) throw new ReviewParseError("No JSON object found in reviewer response");
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
throw new ReviewParseError("Reviewer response is not valid JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new ReviewParseError("Reviewer response must be a JSON object");
}
const row = parsed as Record<string, unknown>;
const verdict = row.verdict;
const summary = row.summary;
if (verdict !== "approve" && verdict !== "revise" && verdict !== "reject") {
throw new ReviewParseError("Reviewer verdict must be approve, revise, or reject");
}
if (typeof summary !== "string" || !summary.trim()) {
throw new ReviewParseError("Reviewer summary must be a non-empty string");
}
return {
verdict,
summary: summary.trim(),
highlights: asStringArray(row.highlights),
lowlights: asStringArray(row.lowlights),
suggestions: asStringArray(row.suggestions),
};
}
function getAssistantText(messages: AgentMessage[]): string {
const lastMessage = messages.filter((message) => message.role === "assistant").pop();
if (!lastMessage?.content) return "";
if (typeof lastMessage.content === "string") return lastMessage.content;
return lastMessage.content
.filter((chunk): chunk is { type: "text"; text: string } => chunk.type === "text")
.map((chunk) => chunk.text)
.join("");
}
function toFailure(memberId: string, error: unknown): ReviewFailure {
if (error instanceof ReviewTimeoutError) {
return { memberId, reason: "timeout", message: error.message };
}
if (error instanceof ReviewParseError) {
return { memberId, reason: "parse_error", message: error.message };
}
if (error instanceof ReviewPanelError && error.reason === "session_unavailable") {
return { memberId, reason: "session_unavailable", message: error.message };
}
return {
memberId,
reason: "exception",
message: error instanceof Error ? error.message : String(error),
};
}
function timeoutAfter(ms: number, memberName: string): Promise<never> {
return new Promise((_, reject) => {
globalThis.setTimeout(() => {
reject(new ReviewTimeoutError(`Review timed out for ${memberName} after ${ms}ms`));
}, ms);
});
}
function dedupeMerged(items: string[]): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const item of items) {
const trimmed = item.trim();
if (!trimmed) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
merged.push(trimmed);
if (merged.length >= MAX_MERGED_ITEMS) break;
}
return merged;
}
function getVerdictRank(verdict: ReviewVerdict): number {
if (verdict === "reject") return 2;
if (verdict === "revise") return 1;
return 0;
}
export function combineReviews(individual: IndividualReview[], failures: ReviewFailure[]): CombinedReview {
if (individual.length === 0) {
return {
overallVerdict: "reject",
consensusSummary: "Review panel could not produce feedback because all reviewers failed.",
mergedHighlights: [],
mergedLowlights: [],
mergedSuggestions: [],
individual,
failures,
};
}
const worstVerdict = individual.reduce<ReviewVerdict>((current, review) =>
getVerdictRank(review.verdict) > getVerdictRank(current) ? review.verdict : current,
"approve");
const consensusSummary = individual
.map((review) => `${review.perspective}: ${review.summary}`)
.join(" | ");
return {
overallVerdict: worstVerdict,
consensusSummary,
mergedHighlights: dedupeMerged(individual.flatMap((review) => review.highlights)),
mergedLowlights: dedupeMerged(individual.flatMap((review) => review.lowlights)),
mergedSuggestions: dedupeMerged(individual.flatMap((review) => review.suggestions)),
individual,
failures,
};
}
async function runSingleReview(member: ReviewPanelMember, input: RunReviewPanelInput, createAiSession: CreateAiSessionFactory, settings: Record<string, unknown>): Promise<IndividualReview> {
const startedAt = Date.now();
const userPrompt = [
"Review the following generated report draft.",
"Return only strict JSON with keys: verdict, summary, highlights, lowlights, suggestions.",
"Do not include markdown fences or extra commentary.",
"",
`reportId: ${input.reportMetadata.reportId}`,
`cadence: ${input.reportMetadata.cadence}`,
`periodStart: ${input.reportMetadata.periodStart}`,
`periodEnd: ${input.reportMetadata.periodEnd}`,
"",
"reportDraft:",
input.reportDraft,
].join("\n");
const response = await Promise.race([
(async () => {
const agent = await createAiSession({
cwd: input.cwd,
systemPrompt: buildSystemPrompt(member, settings),
tools: "readonly",
...(member.provider && member.modelId ? { defaultProvider: member.provider, defaultModelId: member.modelId } : {}),
});
try {
await agent.session.prompt(userPrompt);
let text = getAssistantText(agent.session.state.messages as AgentMessage[]);
let parsed: Omit<IndividualReview, "memberId" | "memberName" | "perspective" | "durationMs" | "rawText"> | undefined;
let lastParseError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
parsed = parseReviewPayload(text);
break;
} catch (error) {
lastParseError = error instanceof Error ? error : new Error(String(error));
if (attempt === MAX_PARSE_RETRIES) break;
await agent.session.prompt("Your previous response was not valid JSON. Respond with only a valid JSON object.");
text = getAssistantText(agent.session.state.messages as AgentMessage[]);
}
}
if (!parsed) {
throw new ReviewParseError(`Failed to parse reviewer response: ${lastParseError?.message ?? "unknown error"}`);
}
return {
memberId: member.id,
memberName: member.name,
perspective: member.perspective,
rawText: text,
durationMs: Date.now() - startedAt,
...parsed,
};
} finally {
(agent.session as { dispose?: () => void }).dispose?.();
}
})(),
timeoutAfter(REVIEW_TIMEOUT_MS, member.name),
]);
return response;
}
export async function runReviewPanel(input: RunReviewPanelInput, ctx: PluginContext): Promise<CombinedReview> {
const createAiSession = pickFactory(ctx);
if (!createAiSession) {
throw new ReviewPanelError("session_unavailable", "AI session factory is unavailable");
}
const settled = await Promise.allSettled(input.panel.map((member) => runSingleReview(member, input, createAiSession, ctx.settings)));
const individual: IndividualReview[] = [];
const failures: ReviewFailure[] = [];
for (let index = 0; index < settled.length; index++) {
const result = settled[index];
const member = input.panel[index];
if (result.status === "fulfilled") individual.push(result.value);
else failures.push(toFailure(member.id, result.reason));
}
return combineReviews(individual, failures);
}
export function __setCreateAiSessionFactory(factory: CreateAiSessionFactory | undefined): void {
injectedCreateAiSession = factory;
}

View File

@@ -0,0 +1,75 @@
export type ReviewVerdict = "approve" | "revise" | "reject";
export interface ReviewPanelMember {
id: string;
name: string;
perspective: string;
promptTemplateId?: string;
provider?: string;
modelId?: string;
}
export interface IndividualReview {
memberId: string;
memberName: string;
perspective: string;
verdict: ReviewVerdict;
summary: string;
highlights: string[];
lowlights: string[];
suggestions: string[];
rawText: string;
durationMs: number;
}
export interface ReviewFailure {
memberId: string;
reason: "timeout" | "parse_error" | "session_unavailable" | "exception";
message: string;
}
export interface CombinedReview {
overallVerdict: ReviewVerdict;
consensusSummary: string;
mergedHighlights: string[];
mergedLowlights: string[];
mergedSuggestions: string[];
individual: IndividualReview[];
failures: ReviewFailure[];
}
export interface RunReviewPanelInput {
reportDraft: string;
reportMetadata: {
reportId: string;
cadence: "daily" | "weekly";
periodStart: string;
periodEnd: string;
};
panel: ReviewPanelMember[];
cwd: string;
}
export class ReviewPanelError extends Error {
constructor(
public readonly reason: "session_unavailable" | "exception",
message: string,
) {
super(message);
this.name = "ReviewPanelError";
}
}
export class ReviewParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ReviewParseError";
}
}
export class ReviewTimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "ReviewTimeoutError";
}
}