feat(FN-2997): merge fusion/fn-2997-2
Merges the FN-2997 research CLI feature (275-line `research` command with routing, error handling, and docs), a droid CLI provider card for onboarding, a consolidated mock helpers module shared across CLI/dashboard/engine, the SSE architecture reference documentation, and the droid CLI probe module Fusion-Task-Id: FN-2997
This commit is contained in:
5
.changeset/add-research-cli-commands.md
Normal file
5
.changeset/add-research-cli-commands.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a new `fn research` command group for managing research runs from the CLI, including create, list, show, export, cancel, and retry flows with JSON-friendly output options.
|
||||||
@@ -48,6 +48,35 @@ During fresh initialization, Fusion also installs the bundled `fusion` skill int
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## `fn research`
|
||||||
|
|
||||||
|
Manage persisted research runs from the CLI.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn research create --query "Compare sqlite WAL vs rollback journal"
|
||||||
|
fn research create --query "Rust async runtime trade-offs" --wait --max-wait-ms 120000
|
||||||
|
fn research list --status failed --limit 20
|
||||||
|
fn research show RR-001
|
||||||
|
fn research export RR-001 --format json --output ./artifacts/research-RR-001.json
|
||||||
|
fn research cancel RR-001
|
||||||
|
fn research retry RR-001 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
| Subcommand | Description |
|
||||||
|
|---|---|
|
||||||
|
| `fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]` | Create a run and optionally wait for completion. |
|
||||||
|
| `fn research list \| ls [--status <status>] [--limit <n>] [--json]` | List recent runs (statuses: `pending`, `running`, `completed`, `failed`, `cancelled`). |
|
||||||
|
| `fn research show <run-id> [--json]` | Show one run with timestamps, summary, and error details. |
|
||||||
|
| `fn research export <run-id> [--format <json\|markdown\|pdf>] [--output <path>] [--json]` | Export run results and persist an export record. |
|
||||||
|
| `fn research cancel <run-id> [--json]` | Request cancellation for an active run. |
|
||||||
|
| `fn research retry <run-id> [--json]` | Create a new retry run from a failed/cancelled run. |
|
||||||
|
|
||||||
|
Disabled/setup behavior mirrors dashboard and agent surfaces:
|
||||||
|
- Feature disabled → `feature-disabled` error (enable research in settings)
|
||||||
|
- Provider unconfigured → `provider-unavailable` error (configure credentials/provider)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `fn dashboard`
|
## `fn dashboard`
|
||||||
|
|
||||||
Start the web dashboard (default port `4040`, bound to `127.0.0.1`).
|
Start the web dashboard (default port `4040`, bound to `127.0.0.1`).
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ fn task logs FN-001 --limit 50 # Limit log lines
|
|||||||
fn task logs FN-001 --type tool # Filter by log type
|
fn task logs FN-001 --type tool # Filter by log type
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Research
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fn research create --query "question" # Create research run
|
||||||
|
fn research create --query "question" --wait # Wait for completion
|
||||||
|
fn research list # List runs
|
||||||
|
fn research ls --status failed --limit 20 # Filter by status
|
||||||
|
fn research show RR-001 # Show one run
|
||||||
|
fn research export RR-001 --format json # Export to JSON
|
||||||
|
fn research export RR-001 --output ./run.md # Export to specific path
|
||||||
|
fn research cancel RR-001 # Cancel active run
|
||||||
|
fn research retry RR-001 # Retry failed/cancelled run
|
||||||
|
```
|
||||||
|
|
||||||
## Mission Management
|
## Mission Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ const commandMocks = vi.hoisted(() => ({
|
|||||||
runPluginEnable: vi.fn(),
|
runPluginEnable: vi.fn(),
|
||||||
runPluginDisable: vi.fn(),
|
runPluginDisable: vi.fn(),
|
||||||
runPluginCreate: vi.fn(),
|
runPluginCreate: vi.fn(),
|
||||||
|
|
||||||
|
runResearchCreate: vi.fn(),
|
||||||
|
runResearchList: vi.fn(),
|
||||||
|
runResearchShow: vi.fn(),
|
||||||
|
runResearchExport: vi.fn(),
|
||||||
|
runResearchCancel: vi.fn(),
|
||||||
|
runResearchRetry: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
|
vi.mock("../commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
|
||||||
@@ -210,6 +217,15 @@ vi.mock("../commands/plugin-scaffold.js", () => ({
|
|||||||
runPluginCreate: commandMocks.runPluginCreate,
|
runPluginCreate: commandMocks.runPluginCreate,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/research.js", () => ({
|
||||||
|
runResearchCreate: commandMocks.runResearchCreate,
|
||||||
|
runResearchList: commandMocks.runResearchList,
|
||||||
|
runResearchShow: commandMocks.runResearchShow,
|
||||||
|
runResearchExport: commandMocks.runResearchExport,
|
||||||
|
runResearchCancel: commandMocks.runResearchCancel,
|
||||||
|
runResearchRetry: commandMocks.runResearchRetry,
|
||||||
|
}));
|
||||||
|
|
||||||
const originalArgv = process.argv;
|
const originalArgv = process.argv;
|
||||||
const originalExit = process.exit;
|
const originalExit = process.exit;
|
||||||
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
|
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
|
||||||
@@ -504,6 +520,66 @@ describe("bin command routing and fallbacks", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("routes research create with options", async () => {
|
||||||
|
await runBin(["research", "create", "--query", "hello world", "--wait", "--max-wait-ms", "1200", "--json", "--project", "alpha"]);
|
||||||
|
expect(commandMocks.runResearchCreate).toHaveBeenCalledWith({
|
||||||
|
query: "hello world",
|
||||||
|
waitForCompletion: true,
|
||||||
|
maxWaitMs: 1200,
|
||||||
|
json: true,
|
||||||
|
projectName: "alpha",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports positional research query and rejects missing query", async () => {
|
||||||
|
await runBin(["research", "create", "hello", "world"]);
|
||||||
|
expect(commandMocks.runResearchCreate).toHaveBeenCalledWith({
|
||||||
|
query: "hello world",
|
||||||
|
waitForCompletion: false,
|
||||||
|
maxWaitMs: undefined,
|
||||||
|
json: false,
|
||||||
|
projectName: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runBin(["research", "create", "--wait"]))
|
||||||
|
.rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Usage: fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes research export", async () => {
|
||||||
|
await runBin(["research", "export", "RR-001", "--format", "json", "--output", "./out.json"]);
|
||||||
|
expect(commandMocks.runResearchExport).toHaveBeenCalledWith({
|
||||||
|
runId: "RR-001",
|
||||||
|
format: "json",
|
||||||
|
output: "./out.json",
|
||||||
|
json: false,
|
||||||
|
projectName: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes research list/show/cancel/retry", async () => {
|
||||||
|
await runBin(["research", "ls", "--status", "failed", "--limit", "5", "--json"]);
|
||||||
|
await runBin(["research", "show", "RR-001", "--json"]);
|
||||||
|
await runBin(["research", "cancel", "RR-001"]);
|
||||||
|
await runBin(["research", "retry", "RR-002", "--json"]);
|
||||||
|
|
||||||
|
expect(commandMocks.runResearchList).toHaveBeenCalledWith({
|
||||||
|
status: "failed",
|
||||||
|
limit: 5,
|
||||||
|
json: true,
|
||||||
|
projectName: undefined,
|
||||||
|
});
|
||||||
|
expect(commandMocks.runResearchShow).toHaveBeenCalledWith("RR-001", { json: true, projectName: undefined });
|
||||||
|
expect(commandMocks.runResearchCancel).toHaveBeenCalledWith("RR-001", { json: false, projectName: undefined });
|
||||||
|
expect(commandMocks.runResearchRetry).toHaveBeenCalledWith("RR-002", { json: true, projectName: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows research subcommand guidance on unknown subcommand", async () => {
|
||||||
|
await expect(runBin(["research", "oops"])).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: research oops");
|
||||||
|
expect(logSpy).toHaveBeenCalledWith("Try: fn research create | list | show | export | cancel | retry");
|
||||||
|
});
|
||||||
|
|
||||||
it("routes desktop flags to runDesktop", async () => {
|
it("routes desktop flags to runDesktop", async () => {
|
||||||
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
|
||||||
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ async function loadCommandHandlers() {
|
|||||||
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
|
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
|
||||||
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
|
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
|
||||||
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
|
||||||
|
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runDashboard,
|
runDashboard,
|
||||||
@@ -214,6 +215,12 @@ async function loadCommandHandlers() {
|
|||||||
runPluginCreate,
|
runPluginCreate,
|
||||||
runSkillsSearch,
|
runSkillsSearch,
|
||||||
runSkillsInstall,
|
runSkillsInstall,
|
||||||
|
runResearchCreate,
|
||||||
|
runResearchList,
|
||||||
|
runResearchShow,
|
||||||
|
runResearchExport,
|
||||||
|
runResearchCancel,
|
||||||
|
runResearchRetry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +269,17 @@ Usage:
|
|||||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||||
Create a GitHub PR for an in-review task
|
Create a GitHub PR for an in-review task
|
||||||
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||||
|
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
|
||||||
|
Create and optionally wait for a research run
|
||||||
|
fn research list | ls [--status <status>] [--limit <n>] [--json]
|
||||||
|
List research runs
|
||||||
|
fn research show <run-id> [--json] Show research run details
|
||||||
|
fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]
|
||||||
|
Export research run results
|
||||||
|
fn research cancel <run-id> [--json]
|
||||||
|
Cancel an active research run
|
||||||
|
fn research retry <run-id> [--json]
|
||||||
|
Retry a failed/cancelled research run
|
||||||
fn mission create [title] [desc] Create a new mission
|
fn mission create [title] [desc] Create a new mission
|
||||||
fn mission list | ls List missions
|
fn mission list | ls List missions
|
||||||
fn mission show | info <id> Show mission details
|
fn mission show | info <id> Show mission details
|
||||||
@@ -489,6 +507,12 @@ async function main() {
|
|||||||
runPluginCreate,
|
runPluginCreate,
|
||||||
runSkillsSearch,
|
runSkillsSearch,
|
||||||
runSkillsInstall,
|
runSkillsInstall,
|
||||||
|
runResearchCreate,
|
||||||
|
runResearchList,
|
||||||
|
runResearchShow,
|
||||||
|
runResearchExport,
|
||||||
|
runResearchCancel,
|
||||||
|
runResearchRetry,
|
||||||
} = await loadCommandHandlers();
|
} = await loadCommandHandlers();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -705,6 +729,85 @@ async function main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "research": {
|
||||||
|
const subcommand = args[1];
|
||||||
|
switch (subcommand) {
|
||||||
|
case "create": {
|
||||||
|
const query = getFlagValue(args, "--query") ?? args.slice(2).filter((value) => !value.startsWith("--")).join(" ").trim();
|
||||||
|
if (!query) {
|
||||||
|
console.error("Usage: fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await runResearchCreate({
|
||||||
|
query,
|
||||||
|
waitForCompletion: args.includes("--wait"),
|
||||||
|
maxWaitMs: getFlagValueNumber(args, "--max-wait-ms"),
|
||||||
|
json: args.includes("--json"),
|
||||||
|
projectName,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "list":
|
||||||
|
case "ls": {
|
||||||
|
const status = getFlagValue(args, "--status");
|
||||||
|
await runResearchList({
|
||||||
|
status,
|
||||||
|
limit: getFlagValueNumber(args, "--limit"),
|
||||||
|
json: args.includes("--json"),
|
||||||
|
projectName,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "show": {
|
||||||
|
const runId = args[2];
|
||||||
|
if (!runId) {
|
||||||
|
console.error("Usage: fn research show <run-id> [--json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await runResearchShow(runId, { json: args.includes("--json"), projectName });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "export": {
|
||||||
|
const runId = args[2];
|
||||||
|
if (!runId) {
|
||||||
|
console.error("Usage: fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await runResearchExport({
|
||||||
|
runId,
|
||||||
|
format: getFlagValue(args, "--format"),
|
||||||
|
output: getFlagValue(args, "--output"),
|
||||||
|
json: args.includes("--json"),
|
||||||
|
projectName,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "cancel": {
|
||||||
|
const runId = args[2];
|
||||||
|
if (!runId) {
|
||||||
|
console.error("Usage: fn research cancel <run-id> [--json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await runResearchCancel(runId, { json: args.includes("--json"), projectName });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "retry": {
|
||||||
|
const runId = args[2];
|
||||||
|
if (!runId) {
|
||||||
|
console.error("Usage: fn research retry <run-id> [--json]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await runResearchRetry(runId, { json: args.includes("--json"), projectName });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
console.error(`Unknown subcommand: research ${subcommand || ""}`);
|
||||||
|
console.log("Try: fn research create | list | show | export | cancel | retry");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "task": {
|
case "task": {
|
||||||
const subcommand = args[1];
|
const subcommand = args[1];
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
|
|||||||
157
packages/cli/src/commands/__tests__/research.test.ts
Normal file
157
packages/cli/src/commands/__tests__/research.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js";
|
||||||
|
|
||||||
|
const mockRun = {
|
||||||
|
id: "RR-001",
|
||||||
|
query: "test query",
|
||||||
|
topic: "test query",
|
||||||
|
status: "completed",
|
||||||
|
sources: [],
|
||||||
|
events: [],
|
||||||
|
tags: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
results: { summary: "done", findings: [], citations: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const researchStoreMock = {
|
||||||
|
getRun: vi.fn(() => mockRun),
|
||||||
|
listRuns: vi.fn(() => [mockRun]),
|
||||||
|
createExport: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeMock = {
|
||||||
|
init: vi.fn(),
|
||||||
|
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchTavilyApiKey: "x" })),
|
||||||
|
getResearchStore: vi.fn(() => researchStoreMock),
|
||||||
|
};
|
||||||
|
|
||||||
|
const orchestratorMock = {
|
||||||
|
createRun: vi.fn(() => "RR-002"),
|
||||||
|
startRun: vi.fn(async () => ({ ...mockRun, id: "RR-002", status: "running" })),
|
||||||
|
cancelRun: vi.fn(() => true),
|
||||||
|
retryRun: vi.fn(() => "RR-003"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => ({
|
||||||
|
resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })),
|
||||||
|
providerRegistryMock: vi.fn(() => ({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) })),
|
||||||
|
writeFileMock: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", () => ({
|
||||||
|
TaskStore: vi.fn(() => storeMock),
|
||||||
|
resolveResearchSettings: resolveResearchSettingsMock,
|
||||||
|
RESEARCH_RUN_STATUSES: ["pending", "running", "completed", "failed", "cancelled"],
|
||||||
|
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@fusion/engine", () => ({
|
||||||
|
ResearchProviderRegistry: providerRegistryMock,
|
||||||
|
ResearchStepRunner: vi.fn(),
|
||||||
|
ResearchOrchestrator: vi.fn(() => orchestratorMock),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) }));
|
||||||
|
vi.mock("node:fs/promises", () => ({ writeFile: writeFileMock }));
|
||||||
|
|
||||||
|
describe("research commands", () => {
|
||||||
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const originalExit = process.exit;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.exit = vi.fn(((code?: number) => {
|
||||||
|
throw new Error(`process.exit:${code ?? 0}`);
|
||||||
|
}) as typeof process.exit);
|
||||||
|
resolveResearchSettingsMock.mockReturnValue({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
|
||||||
|
providerRegistryMock.mockReturnValue({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) });
|
||||||
|
researchStoreMock.getRun.mockReturnValue(mockRun);
|
||||||
|
researchStoreMock.listRuns.mockReturnValue([mockRun]);
|
||||||
|
orchestratorMock.retryRun.mockReturnValue("RR-003");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.exit = originalExit;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a run", async () => {
|
||||||
|
await runResearchCreate({ query: "hello" });
|
||||||
|
expect(orchestratorMock.createRun).toHaveBeenCalled();
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created research run"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists runs as json", async () => {
|
||||||
|
await runResearchList({ json: true, status: "completed", limit: 3 });
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"'));
|
||||||
|
expect(researchStoreMock.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid list status", async () => {
|
||||||
|
await expect(runResearchList({ status: "wat" })).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid status: wat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows one run", async () => {
|
||||||
|
await runResearchShow("RR-001");
|
||||||
|
expect(logSpy).toHaveBeenCalledWith("Run: RR-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails show on missing run", async () => {
|
||||||
|
researchStoreMock.getRun.mockReturnValue(undefined);
|
||||||
|
await expect(runResearchShow("RR-404")).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: Research run not found: RR-404");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports with explicit output path", async () => {
|
||||||
|
await runResearchExport({ runId: "RR-001", format: "json", output: "./out.json" });
|
||||||
|
const writeArgs = writeFileMock.mock.calls[0]!;
|
||||||
|
expect(String(writeArgs[0])).toContain("out.json");
|
||||||
|
expect(String(writeArgs[1])).toContain('"id": "RR-001"');
|
||||||
|
expect(String(writeArgs[1])).toContain('"status": "completed"');
|
||||||
|
expect(String(writeArgs[1])).toContain('"query": "test query"');
|
||||||
|
expect(researchStoreMock.createExport).toHaveBeenCalledWith("RR-001", "json", expect.stringContaining('"id": "RR-001"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports markdown to generated path", async () => {
|
||||||
|
await runResearchExport({ runId: "RR-001", format: "markdown" });
|
||||||
|
expect(writeFileMock).toHaveBeenCalledWith(expect.stringContaining("research-rr-001.md"), expect.stringContaining("## Summary"), "utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a run", async () => {
|
||||||
|
await runResearchCancel("RR-001", { json: true });
|
||||||
|
expect(orchestratorMock.cancelRun).toHaveBeenCalledWith("RR-001");
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"cancelled"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a run", async () => {
|
||||||
|
researchStoreMock.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "pending" } : mockRun));
|
||||||
|
await runResearchRetry("RR-001", { json: true });
|
||||||
|
expect(orchestratorMock.retryRun).toHaveBeenCalledWith("RR-001");
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"retryOf"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when research is disabled", async () => {
|
||||||
|
resolveResearchSettingsMock.mockReturnValue({ enabled: false, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
|
||||||
|
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: feature-disabled: Research is disabled in settings.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when providers are unavailable", async () => {
|
||||||
|
providerRegistryMock.mockReturnValue({ getAvailableProviders: () => [], getProvider: () => undefined });
|
||||||
|
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on invalid export format", async () => {
|
||||||
|
await expect(runResearchExport({ runId: "RR-001", format: "xml" })).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: Unsupported export format: xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors on write failure", async () => {
|
||||||
|
writeFileMock.mockRejectedValueOnce(new Error("disk full"));
|
||||||
|
await expect(runResearchExport({ runId: "RR-001", format: "json", output: "./x.json" })).rejects.toThrow("process.exit:1");
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Error: disk full");
|
||||||
|
});
|
||||||
|
});
|
||||||
275
packages/cli/src/commands/research.ts
Normal file
275
packages/cli/src/commands/research.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
RESEARCH_EXPORT_FORMATS,
|
||||||
|
RESEARCH_RUN_STATUSES,
|
||||||
|
ResearchRunStatus,
|
||||||
|
TaskStore,
|
||||||
|
resolveResearchSettings,
|
||||||
|
type ResearchExportFormat,
|
||||||
|
type ResearchRun,
|
||||||
|
} from "@fusion/core";
|
||||||
|
import { ResearchOrchestrator, ResearchProviderRegistry, ResearchStepRunner } from "@fusion/engine";
|
||||||
|
import { resolveProject } from "../project-context.js";
|
||||||
|
|
||||||
|
interface ResearchCommandOptions {
|
||||||
|
projectName?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchCreateOptions extends ResearchCommandOptions {
|
||||||
|
query: string;
|
||||||
|
waitForCompletion?: boolean;
|
||||||
|
maxWaitMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchListOptions extends ResearchCommandOptions {
|
||||||
|
status?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResearchExportOptions extends ResearchCommandOptions {
|
||||||
|
runId: string;
|
||||||
|
format?: string;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStore(projectName?: string): Promise<TaskStore> {
|
||||||
|
const project = projectName ? await resolveProject(projectName) : undefined;
|
||||||
|
const store = new TaskStore(project?.projectPath ?? process.cwd());
|
||||||
|
await store.init();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResearchRuntime(store: TaskStore) {
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
const resolved = resolveResearchSettings(settings);
|
||||||
|
if (!resolved.enabled) {
|
||||||
|
throw new Error("feature-disabled: Research is disabled in settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new ResearchProviderRegistry(settings, process.cwd());
|
||||||
|
const availableProviderTypes = registry.getAvailableProviders();
|
||||||
|
if (availableProviderTypes.length === 0) {
|
||||||
|
throw new Error("provider-unavailable: Research providers are not configured. Add provider credentials in settings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepRunner = new ResearchStepRunner({
|
||||||
|
providers: availableProviderTypes
|
||||||
|
.map((type) => registry.getProvider(type))
|
||||||
|
.filter((provider): provider is NonNullable<typeof provider> => Boolean(provider)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const orchestrator = new ResearchOrchestrator({
|
||||||
|
store: store.getResearchStore(),
|
||||||
|
stepRunner,
|
||||||
|
maxConcurrentRuns: resolved.limits.maxConcurrentRuns,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { orchestrator, settings, resolved, availableProviderTypes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printRun(run: ResearchRun): void {
|
||||||
|
console.log(`Run: ${run.id}`);
|
||||||
|
console.log(`Status: ${run.status}`);
|
||||||
|
console.log(`Query: ${run.query}`);
|
||||||
|
console.log(`Created: ${run.createdAt}`);
|
||||||
|
console.log(`Updated: ${run.updatedAt}`);
|
||||||
|
if (run.startedAt) console.log(`Started: ${run.startedAt}`);
|
||||||
|
if (run.completedAt) console.log(`Completed: ${run.completedAt}`);
|
||||||
|
if (run.cancelledAt) console.log(`Cancelled: ${run.cancelledAt}`);
|
||||||
|
if (run.results?.summary) console.log(`Summary: ${run.results.summary}`);
|
||||||
|
if (run.error) console.log(`Error: ${run.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonOut(payload: unknown): void {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown): never {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Error: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchCreate(options: ResearchCreateOptions): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore(options.projectName);
|
||||||
|
const { orchestrator, settings, resolved, availableProviderTypes } = await getResearchRuntime(store);
|
||||||
|
|
||||||
|
const runId = orchestrator.createRun({
|
||||||
|
providers: availableProviderTypes
|
||||||
|
.filter((type) => type !== "llm-synthesis")
|
||||||
|
.map((type) => ({ type, config: { maxResults: resolved.limits.maxSourcesPerRun, timeoutMs: resolved.limits.requestTimeoutMs } })),
|
||||||
|
maxSources: resolved.limits.maxSourcesPerRun,
|
||||||
|
maxSynthesisRounds: Math.max(1, settings.researchMaxSynthesisRounds ?? settings.researchGlobalMaxSynthesisRounds ?? 2),
|
||||||
|
phaseTimeoutMs: resolved.limits.maxDurationMs,
|
||||||
|
stepTimeoutMs: resolved.limits.requestTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runPromise = orchestrator.startRun(runId, options.query);
|
||||||
|
if (!options.waitForCompletion) {
|
||||||
|
const run = store.getResearchStore().getRun(runId);
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut(run);
|
||||||
|
} else {
|
||||||
|
console.log(`Created research run ${runId}.`);
|
||||||
|
if (run) printRun(run);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWaitMs = Math.max(1_000, Math.min(options.maxWaitMs ?? 90_000, resolved.limits.maxDurationMs));
|
||||||
|
const completed = await Promise.race([
|
||||||
|
runPromise,
|
||||||
|
new Promise<ResearchRun>((resolveRun) => setTimeout(() => {
|
||||||
|
const latest = store.getResearchStore().getRun(runId);
|
||||||
|
resolveRun(latest ?? ({
|
||||||
|
id: runId,
|
||||||
|
query: options.query,
|
||||||
|
status: "running",
|
||||||
|
sources: [],
|
||||||
|
events: [],
|
||||||
|
tags: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as ResearchRun));
|
||||||
|
}, maxWaitMs)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut(completed);
|
||||||
|
} else {
|
||||||
|
printRun(completed);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchList(options: ResearchListOptions = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore(options.projectName);
|
||||||
|
if (options.status && !RESEARCH_RUN_STATUSES.includes(options.status as ResearchRunStatus)) {
|
||||||
|
throw new Error(`Invalid status: ${options.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runs = store.getResearchStore().listRuns({
|
||||||
|
status: options.status as ResearchRunStatus | undefined,
|
||||||
|
limit: options.limit ? Math.max(1, options.limit) : 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut({ runs });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runs.length) {
|
||||||
|
console.log("No research runs found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
console.log(`${run.id} [${run.status}] ${run.query}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchShow(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore(options.projectName);
|
||||||
|
const run = store.getResearchStore().getRun(runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut(run);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printRun(run);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(run: ResearchRun): string {
|
||||||
|
const citations = run.results?.citations?.length
|
||||||
|
? `\n## Citations\n${run.results.citations.map((citation) => `- ${citation}`).join("\n")}`
|
||||||
|
: "";
|
||||||
|
return `# ${run.topic || run.query}\n\n## Summary\n${run.results?.summary ?? ""}${citations}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchExport(options: ResearchExportOptions): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore(options.projectName);
|
||||||
|
const run = store.getResearchStore().getRun(options.runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${options.runId}`);
|
||||||
|
|
||||||
|
const format = (options.format ?? "markdown") as ResearchExportFormat;
|
||||||
|
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
|
||||||
|
throw new Error(`Unsupported export format: ${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = format === "json" ? JSON.stringify(run, null, 2) : renderMarkdown(run);
|
||||||
|
const ext = format === "json" ? "json" : "md";
|
||||||
|
const outputPath = options.output
|
||||||
|
? resolve(options.output)
|
||||||
|
: join(process.cwd(), `research-${run.id.toLowerCase()}.${ext}`);
|
||||||
|
|
||||||
|
await writeFile(outputPath, content, "utf8");
|
||||||
|
store.getResearchStore().createExport(run.id, format, content);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut({ runId: run.id, format, outputPath, bytes: Buffer.byteLength(content, "utf8") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Exported ${run.id} (${format}) to ${outputPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchCancel(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore(options.projectName);
|
||||||
|
const run = store.getResearchStore().getRun(runId);
|
||||||
|
if (!run) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
const { orchestrator } = await getResearchRuntime(store);
|
||||||
|
const cancelled = orchestrator.cancelRun(runId);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut({ cancelled, run });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(cancelled ? `Cancellation requested for ${runId}.` : `Run ${runId} is not active.`);
|
||||||
|
printRun(run);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchRetry(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const store = await getStore(options.projectName);
|
||||||
|
const existing = store.getResearchStore().getRun(runId);
|
||||||
|
if (!existing) throw new Error(`Research run not found: ${runId}`);
|
||||||
|
|
||||||
|
const { orchestrator } = await getResearchRuntime(store);
|
||||||
|
const newRunId = orchestrator.retryRun(runId);
|
||||||
|
const run = store.getResearchStore().getRun(newRunId);
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
jsonOut({ retryOf: runId, run });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created retry run ${newRunId} from ${runId}.`);
|
||||||
|
if (run) printRun(run);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
|
padding-bottom: calc(var(--space-lg) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.research-view__header {
|
.research-view__header {
|
||||||
@@ -95,7 +96,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.research-view__history-item:hover {
|
.research-view__history-item:hover {
|
||||||
@@ -109,6 +111,10 @@
|
|||||||
box-shadow: var(--focus-ring-strong);
|
box-shadow: var(--focus-ring-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.research-view__history-item:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.research-view__history-item--active {
|
.research-view__history-item--active {
|
||||||
border-color: var(--todo);
|
border-color: var(--todo);
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--focus-ring);
|
||||||
@@ -191,12 +197,12 @@
|
|||||||
.research-view__stat-label {
|
.research-view__stat-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.research-view__stat-value {
|
.research-view__stat-value {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 1rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.research-view__state--error {
|
.research-view__state--error {
|
||||||
@@ -206,6 +212,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.research-view {
|
.research-view {
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
|
padding-bottom: calc(var(--space-md) + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.research-view__layout {
|
.research-view__layout {
|
||||||
|
|||||||
@@ -302,7 +302,12 @@ export function ResearchView({ projectId, addToast, onOpenSettings, readinessVer
|
|||||||
</div>
|
</div>
|
||||||
<div className="research-view__history" data-testid="research-state-running">
|
<div className="research-view__history" data-testid="research-state-running">
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<button key={run.id} className={`research-view__history-item${selectedRunId === run.id ? " research-view__history-item--active" : ""}`} onClick={() => setSelectedRunId(run.id)}>
|
<button
|
||||||
|
key={run.id}
|
||||||
|
type="button"
|
||||||
|
className={`research-view__history-item${selectedRunId === run.id ? " research-view__history-item--active" : ""}`}
|
||||||
|
onClick={() => setSelectedRunId(run.id)}
|
||||||
|
>
|
||||||
<span className="card-id">{run.id}</span>
|
<span className="card-id">{run.id}</span>
|
||||||
<span>{run.title}</span>
|
<span>{run.title}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user