feat(FN-3368): tighten research view test coverage and resolve dashboard ty

This merge introduces an eval automation domain store with persistence schema and a plugin dashboard view registry (FN-3512/FN-3513), adds scheduled eval batch architecture documentation (FN-3388), and includes substantial test coverage for Research routes and hooks (FN-3368 steps 1-3). The branch a

Fusion-Task-Id: FN-3368
This commit is contained in:
Fusion
2026-05-05 13:05:57 -07:00
committed by gsxdsm
parent c36d30f517
commit 0aef085f13
10 changed files with 205 additions and 13 deletions

View File

@@ -145,8 +145,6 @@ export async function runScheduledEvalBatch(
window: {
since: windowStartExclusive,
until: windowEndInclusive,
windowStartExclusive,
windowEndInclusive,
},
metadata: {
windowStartExclusive,

View File

@@ -242,6 +242,12 @@ export const DEFAULT_PROJECT_SETTINGS = {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
insightExtractionMinIntervalMs: 86_400_000,
taskEvaluationEnabled: false,
taskEvaluationSchedule: "0 5 * * *",
taskEvaluationProvider: undefined,
taskEvaluationModelId: undefined,
taskEvaluationFollowUpPolicy: "off",
taskEvaluationRetention: undefined,
memoryEnabled: true,
memoryBackendType: "qmd",
memoryAutoSummarizeEnabled: false,

View File

@@ -1712,6 +1712,18 @@ export interface ProjectSettings {
testCommand?: string;
/** Custom build command for the project (e.g. "pnpm build") */
buildCommand?: string;
/** Enables automated scheduled evaluation of completed tasks. */
taskEvaluationEnabled?: boolean;
/** Cron expression for scheduled task evaluation batches. */
taskEvaluationSchedule?: string;
/** Optional provider override for task evaluation. */
taskEvaluationProvider?: string;
/** Optional model override for task evaluation. */
taskEvaluationModelId?: string;
/** Follow-up behavior for evaluation findings. */
taskEvaluationFollowUpPolicy?: "off" | "suggest" | "create";
/** Number of days to retain evaluation data. */
taskEvaluationRetention?: number;
/** When true, completed task worktrees are returned to an idle pool instead
* of being deleted. New tasks acquire a warm worktree from the pool,
* preserving build caches (node_modules, target/, dist/). Default: false. */

View File

@@ -878,8 +878,12 @@ function AppInner() {
projectId: currentProject?.id,
tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks,
workflowSteps,
openTaskDetail: isMobile ? (task, initialTab) => openDetailTaskWithHistory(task, initialTab) : (task, initialTab) => modalManager.openDetailTask(task, initialTab),
renderTaskCard: (task) => (
openTaskDetail: isMobile
? (task: Task | TaskDetail, initialTab?: Parameters<typeof modalManager.openDetailTask>[1]) =>
openDetailTaskWithHistory(task, initialTab)
: (task: Task | TaskDetail, initialTab?: Parameters<typeof modalManager.openDetailTask>[1]) =>
modalManager.openDetailTask(task, initialTab),
renderTaskCard: (task: Task) => (
<TaskCard
task={task}
projectId={currentProject?.id}

View File

@@ -1453,6 +1453,28 @@ describe("App view switching", () => {
localStorage.removeItem(taskViewStorageKey());
});
it("does not expose research navigation when research feature is disabled", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: {
...defaultSettings.experimentalFeatures,
researchView: false,
},
});
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-research")).not.toBeInTheDocument();
localStorage.removeItem("kb-dashboard-view-mode");
});
it("initializes research view from persisted task-view when feature-enabled", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "research");

View File

@@ -201,12 +201,12 @@ describe("ResearchView", () => {
mockUseResearch.mockReturnValue({
...baseHookValue,
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
runs: [{ id: "RR-1", title: "t", query: "q", status: "queued" }],
selectedRun: {
id: "RR-1",
title: "t",
query: "q",
status: "pending",
status: "queued",
events: [{ id: "E-1", message: "queued" }],
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
},
@@ -239,12 +239,12 @@ describe("ResearchView", () => {
const attachRunToTask = vi.fn().mockResolvedValue({});
mockUseResearch.mockReturnValue({
...baseHookValue,
runs: [{ id: "RR-1", title: "t", query: "q", status: "pending" }],
runs: [{ id: "RR-1", title: "t", query: "q", status: "queued" }],
selectedRun: {
id: "RR-1",
title: "t",
query: "q",
status: "pending",
status: "queued",
events: [],
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
},
@@ -298,7 +298,7 @@ describe("ResearchView", () => {
setSearchQuery,
setSelectedRunId,
runs: [
{ id: "RR-1", title: "Alpha", query: "alpha", status: "pending" },
{ id: "RR-1", title: "Alpha", query: "alpha", status: "queued" },
{ id: "RR-2", title: "Beta", query: "beta", status: "completed" },
],
});
@@ -460,6 +460,66 @@ describe("ResearchView", () => {
expect(await screen.findByTestId("research-state-empty")).toBeInTheDocument();
});
it("wires create-task modal payload with trimmed fields and attachment toggle", async () => {
const createTaskFromRun = vi.fn().mockResolvedValue({});
mockUseResearch.mockReturnValue({
...baseHookValue,
createTaskFromRun,
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
selectedRun: {
id: "RR-1",
title: "t",
query: "q",
status: "completed",
events: [],
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
},
selectedRunId: "RR-1",
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
fireEvent.click((await screen.findAllByText("Create Task"))[0]);
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("Title"), { target: { value: " Follow up task " } });
fireEvent.change(within(dialog).getByLabelText("Description"), { target: { value: " Take action now. " } });
fireEvent.click(within(dialog).getByLabelText("Attach markdown export artifact"));
fireEvent.click(within(dialog).getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(createTaskFromRun).toHaveBeenCalledWith("RR-1", "Follow up task", "finding-1", "Take action now.", "normal", true);
});
});
it("keeps enrich action disabled until a task id is provided", async () => {
mockUseResearch.mockReturnValue({
...baseHookValue,
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
selectedRun: {
id: "RR-1",
title: "t",
query: "q",
status: "completed",
events: [],
results: { summary: "Summary", findings: [{ id: "finding-1", heading: "Finding", content: "Impact." }], citations: [] },
},
selectedRunId: "RR-1",
});
mockFetchAuthStatus.mockResolvedValue({ providers: [{ id: "openrouter", type: "api_key", authenticated: true }] });
render(<ResearchView projectId="p1" />);
fireEvent.click((await screen.findAllByText("Enrich Task"))[0]);
const dialog = await screen.findByRole("dialog");
const enrichButton = within(dialog).getByRole("button", { name: "Enrich Task" });
expect(enrichButton).toBeDisabled();
const targetInput = within(dialog).getByRole("combobox", { name: "Target task" });
fireEvent.change(targetInput, { target: { value: "FN-1" } });
await waitFor(() => expect(enrichButton).not.toBeDisabled());
});
it("includes mobile layout media rule", async () => {
const css = await import("../ResearchView.css?inline");
expect(css.default).toContain("@media (max-width: 768px)");

View File

@@ -35,6 +35,7 @@ vi.mock("../../sse-bus", () => ({
describe("useResearch", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockListResearchRuns.mockResolvedValue({ runs: [], availability: { available: true } });
mockGetResearchRun.mockResolvedValue({ run: { id: "RR-2", title: "t" }, availability: { available: true } });
});
@@ -223,4 +224,32 @@ describe("useResearch", () => {
);
});
});
it("refreshes list and selected run on reconnect", async () => {
let handlers: { onReconnect?: () => void; events?: Record<string, () => void> } = {};
mockSubscribeSse.mockImplementationOnce((_url, opts) => {
handlers = opts;
return vi.fn();
});
const { result } = renderHook(() => useResearch({ projectId: "p1" }));
act(() => {
result.current.setSelectedRunId("RR-2");
});
await waitFor(() => {
expect(mockGetResearchRun).toHaveBeenCalledWith("RR-2", "p1");
});
const listCallsBefore = mockListResearchRuns.mock.calls.length;
act(() => {
handlers.onReconnect?.();
});
await waitFor(() => {
expect(mockListResearchRuns.mock.calls.length).toBeGreaterThan(listCallsBefore);
});
});
});

View File

@@ -1,11 +1,11 @@
import { AlertTriangle } from "lucide-react";
import { lazy, Suspense, type LazyExoticComponent, type ReactNode } from "react";
import { lazy, Suspense, type LazyExoticComponent, type ReactElement, type ReactNode } from "react";
import { ErrorBoundary } from "../components/ErrorBoundary";
import "./pluginViewRegistry.css";
export type PluginTaskView = `plugin:${string}:${string}`;
type PluginViewComponent = LazyExoticComponent<() => JSX.Element>;
type PluginViewComponent = LazyExoticComponent<() => ReactElement>;
const registry = new Map<string, PluginViewComponent>();

View File

@@ -85,6 +85,7 @@ function createMockStore(options?: {
}
return { filename: "RR-1-finding-1.md" };
}),
appendAgentLog: vi.fn(async () => undefined),
log: vi.fn(async () => undefined),
};
}
@@ -197,10 +198,26 @@ describe("research-routes", () => {
expect.objectContaining({
source: expect.objectContaining({
sourceType: "research",
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
sourceRunId: "RR-1",
sourceMetadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1", documentKey: "research-RR-1" }),
}),
}),
);
expect(store.upsertTaskDocument).toHaveBeenCalledWith(
"FN-1",
expect.objectContaining({
key: "research-RR-1",
author: "research",
metadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
}),
);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-1",
expect.stringContaining("Task created from research finding finding-1 in run RR-1"),
"text",
"research-task-integration",
"executor",
);
});
it("enriches existing task from finding and returns revision", async () => {
@@ -221,6 +238,21 @@ describe("research-routes", () => {
expect(response.body.taskId).toBe("FN-42");
expect(response.body.documentKey).toBe("research-RR-1");
expect(response.body.revision).toBe(1);
expect(store.upsertTaskDocument).toHaveBeenCalledWith(
"FN-42",
expect.objectContaining({
key: "research-RR-1",
author: "research",
metadata: expect.objectContaining({ runId: "RR-1", findingId: "finding-1" }),
}),
);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-42",
expect.stringContaining("Task enriched from research finding finding-1 in run RR-1"),
"text",
"research-task-integration",
"executor",
);
});
it("skips duplicate attachment when original name already exists", async () => {
@@ -401,6 +433,29 @@ describe("research-routes", () => {
expect(response.body.error).toContain("attachExport must be a boolean");
});
it("returns 400 when create payload title/description are empty strings", async () => {
const store = createMockStore();
const app = express();
app.use(express.json());
app.use(createResearchRouter(store as any));
const response = await performRequest(
app,
"POST",
"/runs/RR-1/findings/finding-1/task",
JSON.stringify({ title: " ", description: " " }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: "Research: Finding One",
description: expect.stringContaining("Important actionable result."),
}),
);
});
it("returns 400 when attachment exceeds size limit", async () => {
const app = express();
app.use(express.json());