fix(engine): normalize dashboard research providers

This commit is contained in:
Phil Larson
2026-06-11 22:05:35 -07:00
parent 8a4cc12d67
commit 8541db3185
3 changed files with 99 additions and 2 deletions

View File

@@ -113,6 +113,35 @@ describe("ResearchOrchestrator", () => {
expect(status.phase).toBe("completed");
});
it("normalizes dashboard string provider configs before searching", async () => {
const { store, stepRunner } = createHarness();
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 2,
});
const run = store.createRun({
query: "dashboard research",
providerConfig: {
providers: ["web-search", "page-fetch", "llm-synthesis"],
maxResults: 3,
},
});
const completed = await orchestrator.startRun(run.id, "dashboard research");
expect(completed.status).toBe("completed");
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2);
expect(stepRunner.runSourceQuery).toHaveBeenNthCalledWith(1, "dashboard research", "web-search", undefined, expect.anything());
expect(stepRunner.runSourceQuery).toHaveBeenNthCalledWith(2, "dashboard research", "page-fetch", undefined, expect.anything());
expect(store.addEvent).toHaveBeenCalledWith(
run.id,
expect.objectContaining({
message: "Search with web-search started",
}),
);
});
it("cancels a running run", async () => {
const { store, stepRunner } = createHarness();
stepRunner.runSourceQuery.mockImplementation(

View File

@@ -9,6 +9,9 @@ import type {
AutomationStore as AutomationStoreType,
ScheduledTask,
AutomationRunResult,
ResearchModelSettings,
ResearchSynthesisRequest,
ResearchSynthesisResult,
} from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, normalizeMergerMode, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { execFile } from "node:child_process";
@@ -36,6 +39,7 @@ import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchRunDispatcher } from "./research-dispatcher.js";
import { ResearchStepRunner } from "./research-step-runner.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import {
computeVerificationFailureSignature,
@@ -457,9 +461,26 @@ export class ProjectEngine {
const settings = await store.getSettings();
if (typeof (store as { getResearchStore?: () => unknown }).getResearchStore === "function") {
const registry = new ResearchProviderRegistry(settings, cwd);
const providers = registry.getAvailableProviders()
.map((type) => registry.getProvider(type))
.filter((provider): provider is NonNullable<typeof provider> => Boolean(provider));
const synthesisProvider = registry.getProvider("llm-synthesis") as ({
synthesize?: (
request: ResearchSynthesisRequest,
modelSelection: { provider?: string; modelId?: string },
signal?: AbortSignal,
) => Promise<ResearchSynthesisResult>;
} | undefined);
const synthesisRunner = typeof synthesisProvider?.synthesize === "function"
? (request: ResearchSynthesisRequest, _modelSettings: ResearchModelSettings, signal?: AbortSignal) => synthesisProvider.synthesize!(request, {
provider: settings.researchGlobalDefaults?.synthesisProvider ?? settings.defaultProvider,
modelId: settings.researchGlobalDefaults?.synthesisModelId ?? settings.defaultModelId,
}, signal)
: undefined;
this.researchOrchestrator = new ResearchOrchestrator({
store: store.getResearchStore(),
stepRunner: new ResearchStepRunner(),
stepRunner: new ResearchStepRunner({ providers, synthesisRunner }),
maxConcurrentRuns: settings.researchMaxConcurrentRuns ?? 3,
});
this.researchDispatcher = new ResearchRunDispatcher({

View File

@@ -77,7 +77,7 @@ export class ResearchOrchestrator {
const run = this.store.getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
const config = (run.providerConfig ?? {}) as unknown as ResearchOrchestrationConfig;
const config = this.normalizeConfig(run.providerConfig);
const controller = new AbortController();
if (options.abortSignal) {
options.abortSignal.addEventListener("abort", () => controller.abort(options.abortSignal?.reason), { once: true });
@@ -542,6 +542,53 @@ export class ResearchOrchestrator {
return 1 + providers + Math.max(1, config.maxSources) + Math.max(1, config.maxSynthesisRounds) + 1;
}
private normalizeConfig(rawConfig: ResearchRun["providerConfig"]): ResearchOrchestrationConfig {
const raw = (rawConfig ?? {}) as Record<string, unknown>;
const rawProviders = Array.isArray(raw.providers) ? raw.providers : [];
const providers = rawProviders
.map((provider): ResearchOrchestrationConfig["providers"][number] | null => {
if (typeof provider === "string") {
const type = provider.trim();
return type && type !== "llm-synthesis" ? { type } : null;
}
if (provider && typeof provider === "object") {
const candidate = provider as { type?: unknown; config?: unknown };
if (typeof candidate.type === "string" && candidate.type.trim() && candidate.type !== "llm-synthesis") {
return {
type: candidate.type.trim(),
config: candidate.config && typeof candidate.config === "object"
? candidate.config as ResearchOrchestrationConfig["providers"][number]["config"]
: undefined,
};
}
}
return null;
})
.filter((provider): provider is ResearchOrchestrationConfig["providers"][number] => Boolean(provider));
const maxSources = this.positiveNumber(raw.maxSources) ?? this.positiveNumber(raw.maxResults) ?? 20;
const maxSynthesisRounds = this.positiveNumber(raw.maxSynthesisRounds) ?? 2;
return {
providers: providers.length ? providers : [{ type: "web-search" }],
maxSources,
maxSynthesisRounds,
phaseTimeoutMs: this.positiveNumber(raw.phaseTimeoutMs),
stepTimeoutMs: this.positiveNumber(raw.stepTimeoutMs),
rateLimitPerMinute: this.positiveNumber(raw.rateLimitPerMinute),
synthesisModel: raw.synthesisModel && typeof raw.synthesisModel === "object"
? raw.synthesisModel as ResearchOrchestrationConfig["synthesisModel"]
: undefined,
metadata: raw.metadata && typeof raw.metadata === "object"
? raw.metadata as Record<string, unknown>
: undefined,
};
}
private positiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
private statusToPhase(status: ResearchRun["status"]): ResearchOrchestrationPhase {
if (status === "completed") return "completed";
if (status === "failed" || status === "timed_out" || status === "retry_exhausted") return "failed";