Merge branch 'main' into timothyjlaurent/stuck-spinners

This commit is contained in:
gsxdsm
2026-05-05 14:17:58 -07:00
committed by GitHub
81 changed files with 3367 additions and 457 deletions

View File

@@ -36,7 +36,7 @@ import { useCurrentProject } from "./hooks/useCurrentProject";
import { ToastProvider, useToast } from "./hooks/useToast";
import { ConfirmDialogProvider } from "./hooks/useConfirm";
import { useTheme } from "./hooks/useTheme";
import { useModalManager, type DetailTaskOrigin } from "./hooks/useModalManager";
import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager";
import { useAppSettings } from "./hooks/useAppSettings";
import { useDeepLink } from "./hooks/useDeepLink";
import { useFavorites } from "./hooks/useFavorites";
@@ -49,6 +49,7 @@ import { useViewState, type TaskView } from "./hooks/useViewState";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { usePluginDashboardViews } from "./hooks/usePluginDashboardViews";
import { PluginDashboardViewHost } from "./plugins/PluginDashboardViewHost";
import { isPluginViewId } from "./plugins/pluginViewRegistry";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
@@ -448,6 +449,7 @@ function AppInner() {
// Redirect to board if feature-gated views are disabled.
useEffect(() => {
if (!settingsLoaded) return;
if (isPluginViewId(taskView)) return;
if (taskView === "skills" && !skillsEnabled) {
handleChangeTaskView("board");
}
@@ -867,7 +869,7 @@ function AppInner() {
}
// Project view
if (taskView.startsWith("plugin:")) {
if (isPluginViewId(taskView)) {
return (
<PageErrorBoundary>
<PluginDashboardViewHost
@@ -876,8 +878,10 @@ 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?: DetailTaskTab) => openDetailTaskWithHistory(task, initialTab)
: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => modalManager.openDetailTask(task, initialTab),
renderTaskCard: (task: Task | TaskDetail) => (
<TaskCard
task={task}
projectId={currentProject?.id}
@@ -1319,7 +1323,7 @@ function AppInner() {
<NativeShellConnectionStatus state={shellState} onManage={() => setShellConnectionManagerOpen(true)} />
) : undefined}
/>
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !taskView.startsWith("plugin:") && (
{viewMode === "project" && currentProject && taskView !== "chat" && taskView !== "mailbox" && taskView !== "insights" && taskView !== "devserver" && taskView !== "dev-server" && !isPluginViewId(taskView) && (
<QuickChatFAB
projectId={currentProject.id}
addToast={addToast}

View File

@@ -167,6 +167,10 @@
flex-shrink: 0;
}
.agent-detail-import-btn {
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
}
/* Legacy class for backward compatibility */
.agent-detail-title {
display: flex;
@@ -1498,6 +1502,10 @@
min-width: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
}
.agent-detail-import-btn {
min-width: calc(var(--space-xl) * 3 + var(--space-xs));
}
/* Legacy selectors for backward compatibility */
.agent-detail-title {
flex: 1 1 auto;

View File

@@ -5,7 +5,7 @@ import {
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
AlertCircle,
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit, Upload
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -25,6 +25,7 @@ import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { useConfirm } from "../hooks/useConfirm";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { AgentImportModal } from "./AgentImportModal";
/**
* Simple className utility - joins class names conditionally
@@ -131,6 +132,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const [agent, setAgent] = useState<AgentDetail | null>(null);
const { confirm } = useConfirm();
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabId>(initialTab ?? "dashboard");
const [isStreaming, setIsStreaming] = useState(false);
@@ -613,6 +615,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
{/* Utility actions: refresh + close */}
<div className="agent-detail-utility-actions">
<button
type="button"
className="btn btn--compact agent-detail-import-btn"
onClick={() => setIsImportModalOpen(true)}
aria-label="Import agents"
>
<Upload size={14} />
Import
</button>
<button className="btn-icon" onClick={() => void loadAgent()} title="Refresh" aria-label="Refresh">
<RefreshCw size={16} />
</button>
@@ -757,6 +768,15 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
</div>
)}
</div>
<AgentImportModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
onImported={() => {
void handleSavedMutation();
}}
projectId={projectId}
initialInputMethod="browse"
/>
</div>
);
}

View File

@@ -9,6 +9,7 @@ export interface AgentImportModalProps {
onClose: () => void;
onImported: () => void;
projectId?: string;
initialInputMethod?: InputMethod;
}
/** Parsed agent preview item for display before import */
@@ -126,10 +127,10 @@ function parseDirectoryAgentManifest(content: string): DirectoryAgentInput {
*
* Flow: Input → Preview parsed agents → Import → Show results
*/
export function AgentImportModal({ isOpen, onClose, onImported, projectId }: AgentImportModalProps) {
export function AgentImportModal({ isOpen, onClose, onImported, projectId, initialInputMethod = "paste" }: AgentImportModalProps) {
useMobileScrollLock(isOpen);
const [step, setStep] = useState<ModalStep>("input");
const [inputMethod, setInputMethod] = useState<InputMethod>("paste");
const [inputMethod, setInputMethod] = useState<InputMethod>(initialInputMethod);
const [manifestContent, setManifestContent] = useState("");
const [directoryAgents, setDirectoryAgents] = useState<DirectoryAgentInput[]>([]);
const [companyName, setCompanyName] = useState("Unknown");
@@ -207,7 +208,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
const reset = useCallback(() => {
setStep("input");
setInputMethod("paste");
setInputMethod(initialInputMethod);
setManifestContent("");
setDirectoryAgents([]);
setCompanyName("Unknown");
@@ -226,7 +227,7 @@ export function AgentImportModal({ isOpen, onClose, onImported, projectId }: Age
setIsLoadingCompanies(false);
setCompaniesError(null);
fetchAttemptedRef.current = false;
}, []);
}, [initialInputMethod]);
const handleClose = useCallback(() => {
reset();

View File

@@ -13,7 +13,7 @@ import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
import { getTrailingPath } from "../utils/pathDisplay";
import type { TaskView } from "../hooks/useViewState";
import type { PluginDashboardViewEntry } from "../api";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
export { useViewportMode };
@@ -1152,7 +1152,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || view.startsWith("plugin:") ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "roadmaps", "insights", "memory", "dev-server", "devserver"].includes(view) || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"

View File

@@ -35,7 +35,7 @@ import { fetchScripts } from "../api";
import type { PluginDashboardViewEntry } from "../api";
import { useViewportMode } from "./Header";
import type { TaskView } from "../hooks/useViewState";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
import { getPluginNavIcon } from "./pluginNavIcon";
export interface MobileNavBarProps {
@@ -247,7 +247,7 @@ export function MobileNavBar({
|| (todosOpen && todoViewEnabled)
|| (view === "roadmaps" && !showRoadmapsTopLevel)
|| (view === "skills" && !showSkillsTopLevel)
|| (view.startsWith("plugin:") && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
return (
<>

View File

@@ -1307,6 +1307,33 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
[projectId, sessionTabId, view]
);
const handleRefineFurther = useCallback(async () => {
if (view.type !== "summary") {
return;
}
const { session, summary } = view;
const sessionId = session.sessionId;
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
setError(null);
setIsRetrying(false);
setStreamingOutput("");
setView({ type: "loading" });
connectToPlanningStream(sessionId);
try {
await respondToPlanning(sessionId, { refine: true }, projectId, sessionTabId);
} catch (err) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setError(getErrorMessage(err) || "Failed to refine plan");
setView({ type: "summary", session, summary: editedSummary ?? summary });
}
}, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]);
const handleStopGeneration = useCallback(async () => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) {
@@ -1935,8 +1962,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onCreateTask={handleCreateTask}
onBreakIntoTasks={handleStartBreakdown}
onRefine={() => {
// Reset to question mode for more refinement
setView({ type: "question", session: view.session });
void handleRefineFurther();
}}
isLoading={false}
/>

View File

@@ -38,6 +38,7 @@ vi.mock("../../api", () => ({
fetchPluginRuntimes: vi.fn(),
upgradeAgentHeartbeatProcedure: vi.fn(),
updateGlobalSettings: vi.fn(),
fetchCompanies: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -118,7 +119,7 @@ vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: mockConfirm }),
}));
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings } from "../../api";
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchSkillContent, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchCompanies } from "../../api";
import { subscribeSse } from "../../sse-bus";
const mockFetchAgent = vi.mocked(fetchAgent);
@@ -146,6 +147,7 @@ const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
const mockFetchCompanies = vi.mocked(fetchCompanies);
const mockSubscribeSse = vi.mocked(subscribeSse);
const MOCK_SKILLS = [
@@ -249,6 +251,7 @@ describe("AgentDetailView", () => {
procedureFileSeeded: true,
});
mockUpdateGlobalSettings.mockResolvedValue({} as any);
mockFetchCompanies.mockResolvedValue({ companies: [] });
});
it("shows loading state initially", () => {
@@ -936,11 +939,32 @@ describe("AgentDetailView", () => {
const utilityContainer = headerActions?.querySelector(".agent-detail-utility-actions");
expect(utilityContainer).toBeTruthy();
expect(utilityContainer?.querySelector('[aria-label="Import agents"]')).toBeTruthy();
expect(utilityContainer?.querySelector('[title="Refresh"]')).toBeTruthy();
expect(utilityContainer?.querySelector('[title="Close"]')).toBeTruthy();
});
});
it("opens the import modal from agent detail in browse mode", async () => {
const user = userEvent.setup();
mockFetchCompanies.mockResolvedValue({ companies: [{ slug: "acme", name: "Acme AI" }] });
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>,
);
await user.click(await screen.findByRole("button", { name: "Import agents" }));
await waitFor(() => {
expect(screen.getByRole("dialog", { name: "Import agents" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search companies...")).toBeInTheDocument();
});
});
it("keeps mobile inline header controls on the same row as identity", () => {
const stylesContent = loadAllAppCss();

View File

@@ -184,4 +184,11 @@ describe("AgentImportModal", () => {
// The browse mode should render the search input (the fetch for companies is async)
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
});
it("opens directly in browse mode when initialInputMethod is browse", () => {
render(<AgentImportModal isOpen={true} onClose={onClose} onImported={onImported} initialInputMethod="browse" />);
expect(screen.getByPlaceholderText("Search companies...")).toBeTruthy();
expect(screen.queryByLabelText("Manifest content")).toBeNull();
});
});

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");
@@ -1645,8 +1667,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText("Zoom In")).toBeInTheDocument();
expect(screen.getByText("Zoom Out")).toBeInTheDocument();
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());

View File

@@ -809,6 +809,84 @@ describe("PlanningModeModal", () => {
expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", resumedSummary, undefined);
});
});
it("refines a resumed complete session without blank question view", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-and-refine",
description: "Recovered summary for refine",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
const refinedQuestion: PlanningQuestion = {
id: "q-refine",
type: "text",
question: "Which part should we refine?",
description: "Refine follow-up",
};
mockFetchAiSession.mockResolvedValueOnce({
id: "session-complete-refine",
type: "planning",
status: "complete",
title: "Resume-and-refine",
inputPayload: JSON.stringify({ initialPlan: "Recover and refine" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementationOnce(async () => {
setTimeout(() => {
streamHandlers?.onQuestion?.(refinedQuestion);
}, 10);
return { type: "question", data: refinedQuestion };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-complete-refine"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Refine Further" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Refine Further" }));
await waitFor(() => {
expect(mockRespondToPlanning).toHaveBeenCalledWith(
"session-complete-refine",
{ refine: true },
undefined,
expect.any(String),
);
});
await waitFor(() => {
expect(screen.getByText("Which part should we refine?")).toBeDefined();
});
expect(screen.queryByText("No active question in session")).toBeNull();
});
});
describe("Conversation history", () => {
@@ -1094,7 +1172,7 @@ describe("PlanningModeModal", () => {
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
});
}, { timeout: 5000 });
expect(screen.getByTestId("conversation-history")).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();

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

@@ -42,6 +42,7 @@ vi.mock("../../api", () => ({
fetchAgentBudgetStatus: vi.fn(),
resetAgentBudget: vi.fn(),
upgradeAgentHeartbeatProcedure: vi.fn(),
fetchCompanies: vi.fn(),
}));
vi.mock("../AgentLogViewer", () => ({
@@ -80,6 +81,7 @@ const mockGenerateAgentSpec = vi.mocked(api.generateAgentSpec);
const mockCancelAgentGeneration = vi.mocked(api.cancelAgentGeneration);
const mockFetchAgentBudgetStatus = vi.mocked(api.fetchAgentBudgetStatus);
const mockResetAgentBudget = vi.mocked(api.resetAgentBudget);
const mockFetchCompanies = vi.mocked(api.fetchCompanies);
const originalFetch = globalThis.fetch;
@@ -172,6 +174,7 @@ describe("agent modal mobile CSS structure", () => {
mockCancelAgentGeneration.mockResolvedValue({ success: true });
mockFetchAgentBudgetStatus.mockResolvedValue({ agentId: "agent-001", currentUsage: 0, budgetLimit: null, usagePercent: null, thresholdPercent: null, isOverBudget: false, isOverThreshold: false, lastResetAt: null, nextResetAt: null });
mockResetAgentBudget.mockResolvedValue(undefined);
mockFetchCompanies.mockResolvedValue({ companies: [] });
globalThis.fetch = vi.fn(async () =>
({
@@ -273,6 +276,12 @@ describe("agent modal mobile CSS structure", () => {
expect(document.querySelector(".agent-import-dialog")).toBeTruthy();
});
it("supports browse-first launch mode", () => {
render(<AgentImportModal isOpen={true} onClose={vi.fn()} onImported={vi.fn()} initialInputMethod="browse" />);
expect(screen.getByPlaceholderText("Search companies...")).toBeInTheDocument();
});
it("file upload area has targetable class", () => {
render(<AgentImportModal isOpen={true} onClose={vi.fn()} onImported={vi.fn()} />);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { usePluginDashboardViews, __test_clearDashboardViewsCache } from "../usePluginDashboardViews";
import * as api from "../../api";
@@ -15,6 +15,14 @@ describe("usePluginDashboardViews", () => {
mockFetch.mockReset();
});
it("returns empty array when no dashboard views are registered", async () => {
mockFetch.mockResolvedValueOnce([]);
const { result } = renderHook(() => usePluginDashboardViews());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views).toEqual([]);
expect(result.current.error).toBeNull();
});
it("fetches and returns dashboard views", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
@@ -25,6 +33,60 @@ describe("usePluginDashboardViews", () => {
expect(result.current.views).toHaveLength(1);
});
it("caches results and doesn't re-fetch within ttl", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
]);
const first = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(first.result.current.loading).toBe(false));
mockFetch.mockClear();
const second = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(second.result.current.loading).toBe(false));
expect(mockFetch).not.toHaveBeenCalled();
});
it("sets loading only on initial fetch, not on cache-hit", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "dep", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
]);
const first = renderHook(() => usePluginDashboardViews("project-a"));
expect(first.result.current.loading).toBe(true);
await waitFor(() => expect(first.result.current.loading).toBe(false));
mockFetch.mockClear();
const second = renderHook(() => usePluginDashboardViews("project-a"));
expect(second.result.current.loading).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
});
it("handles fetch errors gracefully", async () => {
mockFetch.mockRejectedValueOnce(new Error("boom"));
const { result } = renderHook(() => usePluginDashboardViews());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views).toEqual([]);
expect(result.current.error).toBe("boom");
});
it("refetch invalidates cache and fetches again", async () => {
mockFetch
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }])
.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "y", label: "Y", componentPath: "./y.js" } }]);
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.views[0]?.view.viewId).toBe("x");
await act(async () => {
result.current.refetch();
});
await waitFor(() => expect(result.current.views[0]?.view.viewId).toBe("y"));
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses project-scoped cache keys", async () => {
mockFetch.mockResolvedValueOnce([{ pluginId: "a", view: { viewId: "x", label: "X", componentPath: "./x.js" } }]);
const first = renderHook(() => usePluginDashboardViews("project-a"));

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

@@ -329,6 +329,22 @@ describe("useViewState", () => {
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
it("rejects invalid plugin view IDs and falls back to board", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:only-one-segment");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
});
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;

View File

@@ -1,24 +1,37 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchPluginDashboardViews } from "../api";
import type { PluginDashboardViewEntry } from "../api";
const dashboardViewsCache = new Map<string, { views: PluginDashboardViewEntry[]; expiresAt: number }>();
const CACHE_TTL_MS = 60_000;
/** Clear module cache for deterministic hook tests. */
export function __test_clearDashboardViewsCache(): void {
dashboardViewsCache.clear();
}
/**
* Fetch plugin dashboard views with a 60s project-scoped cache.
* Loading is only true for the first fetch of each hook lifecycle.
*/
export function usePluginDashboardViews(projectId?: string): {
views: PluginDashboardViewEntry[];
loading: boolean;
error: string | null;
refetch: () => void;
} {
const [views, setViews] = useState<PluginDashboardViewEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const initialLoadCompleteRef = useRef(false);
const refetch = useCallback(() => {
const cacheKey = projectId ?? "default";
dashboardViewsCache.delete(cacheKey);
setReloadKey((key) => key + 1);
}, [projectId]);
useEffect(() => {
const cacheKey = projectId ?? "default";
let cancelled = false;
@@ -57,7 +70,7 @@ export function usePluginDashboardViews(projectId?: string): {
return () => {
cancelled = true;
};
}, [projectId]);
}, [projectId, reloadKey]);
return useMemo(() => ({ views, loading, error }), [views, loading, error]);
return useMemo(() => ({ views, loading, error, refetch }), [views, loading, error, refetch]);
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "@fusion/core";
import type { ProjectInfo } from "../api";
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { isPluginViewId } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "agents" | "missions" | "chat" | "documents" | "research" | "roadmaps" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
@@ -29,12 +30,8 @@ function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
return value !== null && BUILT_IN_TASK_VIEWS.includes(value as BuiltInTaskView);
}
function isPluginTaskView(value: string | null): value is PluginTaskView {
return value !== null && /^plugin:[^:]+:.+$/u.test(value);
}
function isTaskView(value: string | null): value is TaskView {
return isBuiltInTaskView(value) || isPluginTaskView(value);
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
}
function normalizeTaskView(value: TaskView): TaskView {

View File

@@ -1,20 +1,6 @@
import { resolvePluginDashboardView, MissingPluginDashboardView, parsePluginTaskViewId } from "./pluginViewRegistry";
import type { PluginDashboardHostContext, PluginTaskView } from "./pluginViewRegistry";
import { PluginDashboardViewHost as RegistryPluginDashboardViewHost } from "./pluginViewRegistry";
import type { PluginTaskView } from "./pluginViewRegistry";
export function PluginDashboardViewHost({
taskView,
context,
}: {
taskView: PluginTaskView;
context: PluginDashboardHostContext;
}) {
const parsed = parsePluginTaskViewId(taskView);
if (!parsed) return null;
const ViewComponent = resolvePluginDashboardView(parsed.pluginId, parsed.viewId);
if (!ViewComponent) {
return <>{MissingPluginDashboardView({ pluginId: parsed.pluginId, viewId: parsed.viewId })}</>;
}
return <ViewComponent context={context} />;
export function PluginDashboardViewHost({ taskView }: { taskView: PluginTaskView; context?: unknown }) {
return <RegistryPluginDashboardViewHost viewId={taskView} />;
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it, beforeEach } from "vitest";
import { lazy } from "react";
import { render, screen } from "@testing-library/react";
import {
__test_clearPluginViewRegistry,
getPluginViewComponent,
getPluginViewId,
isPluginViewId,
parsePluginViewId,
PluginDashboardViewHost,
registerPluginView,
} from "../pluginViewRegistry";
describe("pluginViewRegistry", () => {
beforeEach(() => {
__test_clearPluginViewRegistry();
});
it("builds plugin IDs", () => {
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main");
});
it("parses and validates plugin IDs", () => {
expect(parsePluginViewId("plugin:plugin-a:main")).toEqual({ pluginId: "plugin-a", viewId: "main" });
expect(parsePluginViewId("board")).toBeNull();
expect(isPluginViewId("plugin:plugin-a:main")).toBe(true);
expect(isPluginViewId("plugin:only-one-segment")).toBe(false);
});
it("registers and resolves view components", () => {
const View = lazy(async () => ({ default: () => <div>Plugin View</div> }));
registerPluginView("plugin-a", "main", View);
expect(getPluginViewComponent("plugin-a", "main")).toBe(View);
expect(getPluginViewComponent("plugin-b", "missing")).toBeNull();
});
it("renders registered components", async () => {
const View = lazy(async () => ({ default: () => <div>Rendered Plugin View</div> }));
registerPluginView("plugin-a", "main", View);
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:main" })}</>);
expect(await screen.findByText("Rendered Plugin View")).toBeInTheDocument();
});
it("renders unavailable fallback for unregistered views", () => {
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>);
expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument();
});
});

View File

@@ -1,64 +1,82 @@
import { AlertTriangle } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
import { DependencyGraphView } from "@fusion-plugin-examples/dependency-graph/dashboard-view";
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}`;
export interface PluginDashboardHostContext {
projectId?: string;
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: "logs" | "changes") => void;
renderTaskCard: (task: Task) => ReactNode;
}
type PluginViewComponent = LazyExoticComponent<() => ReactElement>;
export interface PluginDashboardViewComponentProps {
context: PluginDashboardHostContext;
}
const registry = new Map<string, PluginViewComponent>();
export interface PluginDashboardViewRegistration {
pluginId: string;
viewId: string;
component: ComponentType<PluginDashboardViewComponentProps>;
}
const REGISTRY: PluginDashboardViewRegistration[] = [
{
pluginId: "fusion-plugin-dependency-graph",
viewId: "graph",
component: DependencyGraphView as ComponentType<PluginDashboardViewComponentProps>,
},
];
export function buildPluginTaskViewId(pluginId: string, viewId: string): PluginTaskView {
/** Build composite plugin task view ID: plugin:{pluginId}:{viewId}. */
export function getPluginViewId(pluginId: string, viewId: string): PluginTaskView {
return `plugin:${pluginId}:${viewId}`;
}
export function parsePluginTaskViewId(taskView: string): { pluginId: string; viewId: string } | null {
if (!taskView.startsWith("plugin:")) return null;
const [, pluginId, ...viewParts] = taskView.split(":");
const viewId = viewParts.join(":");
if (!pluginId || !viewId) return null;
return { pluginId, viewId };
/** Parse composite plugin task view ID. Returns null for non-plugin IDs. */
export function parsePluginViewId(value: string): { pluginId: string; viewId: string } | null {
const match = /^plugin:([^:]+):(.+)$/u.exec(value);
if (!match) return null;
return { pluginId: match[1], viewId: match[2] };
}
export function resolvePluginDashboardView(pluginId: string, viewId: string): ComponentType<PluginDashboardViewComponentProps> | null {
const hit = REGISTRY.find((entry) => entry.pluginId === pluginId && entry.viewId === viewId);
return hit?.component ?? null;
/** True when a view ID matches the plugin composite ID format. */
export function isPluginViewId(value: string): value is PluginTaskView {
return parsePluginViewId(value) !== null;
}
export function MissingPluginDashboardView({ pluginId, viewId }: { pluginId: string; viewId: string }): ReactNode {
/** Register a lazy plugin dashboard view component in the static host registry. */
export function registerPluginView(pluginId: string, viewId: string, lazyComponent: PluginViewComponent): void {
registry.set(getPluginViewId(pluginId, viewId), lazyComponent);
}
/** Resolve a plugin dashboard lazy component from the static host registry. */
export function getPluginViewComponent(pluginId: string, viewId: string): PluginViewComponent | null {
return registry.get(getPluginViewId(pluginId, viewId)) ?? null;
}
/** Test helper for clearing global registry state. */
export function __test_clearPluginViewRegistry(): void {
registry.clear();
}
function PluginViewUnavailable({ viewId }: { viewId: string }): ReactNode {
return (
<section className="card plugin-dashboard-view-missing">
<section className="card plugin-dashboard-view-missing" data-testid="plugin-view-unavailable">
<h2 className="plugin-dashboard-view-missing-title">
<AlertTriangle />
Plugin view unavailable
</h2>
<p className="plugin-dashboard-view-missing-description">
The dashboard could not resolve <code>{pluginId}:{viewId}</code> from the host registry.
No host registration found for <code>{viewId}</code>.
</p>
</section>
);
}
export function PluginDashboardViewHost({ viewId }: { viewId: PluginTaskView }): ReactNode {
const parsed = parsePluginViewId(viewId);
if (!parsed) return <PluginViewUnavailable viewId={viewId} />;
const ViewComponent = getPluginViewComponent(parsed.pluginId, parsed.viewId);
if (!ViewComponent) {
return <PluginViewUnavailable viewId={viewId} />;
}
return (
<ErrorBoundary fallback={<PluginViewUnavailable viewId={viewId} />}>
<Suspense fallback={null}>
<ViewComponent />
</Suspense>
</ErrorBoundary>
);
}
// Backward-compatible aliases.
export const buildPluginTaskViewId = getPluginViewId;
export const parsePluginTaskViewId = parsePluginViewId;
export const resolvePluginDashboardView = getPluginViewComponent;
// Ensure lazy is referenced for plugin authors importing only this module pattern.
void lazy;

View File

@@ -839,7 +839,7 @@ describe("planning module", () => {
await expect(submitResponse("invalid-session-id", {})).rejects.toThrow(SessionNotFoundError);
});
it("throws InvalidSessionStateError when no active question", async () => {
it("throws InvalidSessionStateError when no active question and not refining", async () => {
const mockIp = getUniqueIp();
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
@@ -852,6 +852,88 @@ describe("planning module", () => {
await expect(submitResponse(sessionId, {})).rejects.toThrow(InvalidSessionStateError);
});
it("continues from summary when refine is requested", async () => {
const mockIp = getUniqueIp();
setupMockAgent([
...STANDARD_QUESTION_RESPONSES,
JSON.stringify({
type: "question",
data: {
id: "q-refine",
type: "text",
question: "What should we tighten in this plan?",
description: "Refine follow-up",
},
}),
]);
const { sessionId } = await createSession(mockIp, initialPlan, undefined, TEST_ROOT_DIR);
await submitResponse(sessionId, { scope: "small" }, TEST_ROOT_DIR);
await submitResponse(sessionId, { requirements: "test" }, TEST_ROOT_DIR);
await submitResponse(sessionId, { confirm: true }, TEST_ROOT_DIR);
const response = await submitResponse(sessionId, { refine: true }, TEST_ROOT_DIR);
expect(response.type).toBe("question");
if (response.type === "question") {
expect(response.data.id).toBe("q-refine");
}
expect(getSummary(sessionId)).toBeUndefined();
});
it("rehydrates a completed persisted session and refines from summary", async () => {
const store = new MockAiSessionStore();
const summary = {
title: "Recovered summary",
description: "Recovered summary description",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Deliverable"],
};
const row = buildPlanningRow({
id: "planning-complete-refine",
status: "complete",
conversationHistory: JSON.stringify([
{
question: {
id: "q-existing",
type: "text",
question: "What should we build?",
description: "baseline",
},
response: { "q-existing": "A useful feature" },
},
]),
currentQuestion: "null",
result: JSON.stringify(summary),
});
store.rows.set(row.id, row);
setAiSessionStore(store as any);
const resumedAgent = createMockAgent([
JSON.stringify({
type: "question",
data: {
id: "q-refine-rehydrated",
type: "text",
question: "Any additional constraints?",
description: "Refine resumed",
},
}),
]);
const createFnAgentSpy = vi.fn(async () => resumedAgent);
__setCreateFnAgent(createFnAgentSpy as any);
const response = await submitResponse(row.id, { refine: true }, TEST_ROOT_DIR);
expect(response.type).toBe("question");
if (response.type === "question") {
expect(response.data.id).toBe("q-refine-rehydrated");
}
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Refine Further");
});
it("reconstructs agent for a rehydrated session and continues conversation", async () => {
const store = new MockAiSessionStore();
const row = buildPlanningRow({

View File

@@ -703,6 +703,16 @@ describe("GET /api/plugins/dashboard-views", () => {
return app;
}
it("returns empty array when pluginLoader is not available", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { pluginStore }));
const res = await performGet(app, "/api/plugins/dashboard-views");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns 200 with empty array when no plugins have dashboard views", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([]);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");

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());

View File

@@ -991,6 +991,50 @@ describe("Planning Mode Routes", () => {
expect(finalRes.body.data.keyDeliverables).toBeInstanceOf(Array);
});
it("allows refine requests from completed sessions", async () => {
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
{ "Content-Type": "application/json" }
);
await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { confirm: true } }),
{ "Content-Type": "application/json" }
);
const refineRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/respond",
JSON.stringify({ sessionId, responses: { refine: true } }),
{ "Content-Type": "application/json" }
);
expect(refineRes.status).toBe(200);
expect(["question", "complete"]).toContain(refineRes.body.type);
});
it("returns 404 for invalid session ID", async () => {
const res = await REQUEST(
buildApp(),

View File

@@ -1231,8 +1231,8 @@ export function __setCreateFnAgent(mock: typeof createFnAgent): void {
// hit the real engine. Mirror the same fake into the resolved-session slot
// so existing test setups that only call `__setCreateFnAgent` continue to
// work.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createResolvedAgentSession = (async (options: any) => mock(options)) as typeof createResolvedAgentSession;
createResolvedAgentSession = (async (options: Parameters<typeof createResolvedAgentSession>[0]) =>
mock(options)) as typeof createResolvedAgentSession;
}
/**

View File

@@ -1611,6 +1611,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
if (parsed.type === "question") {
session.currentQuestion = parsed.data;
session.summary = undefined;
session.error = undefined;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date();
@@ -1835,6 +1836,20 @@ export function parseAgentResponse(text: string): PlanningResponse {
* Submit a response to the current question and get the next question or summary.
* Supports both stubbed mode and AI agent mode.
*/
function isRefineRequest(responses: Record<string, unknown>): boolean {
return responses.refine === true;
}
function formatRefineRequestForAgent(summary: PlanningSummary): string {
return [
"The user clicked Refine Further on the planning summary.",
"Continue the planning interview from the existing context.",
"Either ask one focused follow-up question or return an updated completion summary if sufficient.",
"Current summary:",
JSON.stringify(summary),
].join("\n\n");
}
export async function submitResponse(
sessionId: string,
responses: Record<string, unknown>,
@@ -1847,26 +1862,35 @@ export async function submitResponse(
}
if (!session.currentQuestion) {
throw new InvalidSessionStateError("No active question in session");
if (!isRefineRequest(responses) || !session.summary) {
throw new InvalidSessionStateError("No active question in session");
}
session.error = undefined;
persistSession(session, "generating");
await ensureSessionAgent(session, rootDir, session.history, promptOverrides);
const refineMessage = formatRefineRequestForAgent(session.summary);
await continueAgentConversation(session, refineMessage);
} else {
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
});
session.error = undefined;
persistSession(session, "generating");
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
await continueAgentConversation(session, message);
}
// Record the response
session.history.push({
question: session.currentQuestion,
response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
});
session.error = undefined;
persistSession(session, "generating");
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
await continueAgentConversation(session, message);
// Return the current state (will be updated via SSE)
if (session.summary) {
return { type: "complete", data: session.summary };

View File

@@ -935,6 +935,19 @@ export function createBatchImportRateLimiter(): (req: Request, res: Response, ne
};
}
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
return {
sourceIssue: {
provider: "github" as const,
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
};
}
export function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null {
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
@@ -2082,21 +2095,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
sourceMetadata: source.sourceMetadata,
},
});
@@ -2222,21 +2230,16 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const description = `${body}\n\nSource: ${sourceUrl}`;
try {
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await scopedStore.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
sourceMetadata: source.sourceMetadata,
},
});

View File

@@ -21,6 +21,18 @@ export default defineConfig({
__dirname,
"../../plugins/fusion-plugin-droid-runtime/src/index.ts",
),
"@fusion-plugin-examples/hermes-runtime": resolve(
__dirname,
"../../plugins/fusion-plugin-hermes-runtime/src/index.ts",
),
"@fusion-plugin-examples/openclaw-runtime": resolve(
__dirname,
"../../plugins/fusion-plugin-openclaw-runtime/src/index.ts",
),
"@fusion-plugin-examples/paperclip-runtime": resolve(
__dirname,
"../../plugins/fusion-plugin-paperclip-runtime/src/index.ts",
),
},
},
test: {