feat(FN-2260): merge fusion/fn-2260

This commit is contained in:
Fusion
2026-04-22 13:47:00 -07:00
committed by gsxdsm
parent 794da6a030
commit a1409389a2
32 changed files with 2419 additions and 423 deletions

View File

@@ -68,6 +68,8 @@ export interface HeartbeatMonitorOptions {
/** Project root directory for agent session CWD.
* When not provided, executeHeartbeat() will throw. */
rootDir?: string;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
}
/** Options for waking up an agent */
@@ -240,6 +242,7 @@ export class HeartbeatMonitor {
private taskStore?: TaskStore;
private rootDir?: string;
private messageStore?: MessageStore;
private pluginRunner?: import("./plugin-runner.js").PluginRunner;
private trackedAgents: Map<string, TrackedAgent> = new Map();
private agentStartLocks: Map<string, Promise<unknown>> = new Map();
@@ -263,6 +266,14 @@ export class HeartbeatMonitor {
this.taskStore = options.taskStore;
this.rootDir = options.rootDir;
this.messageStore = options.messageStore;
this.pluginRunner = options.pluginRunner;
this.onRecovered = options.onRecovered;
this.onTerminated = options.onTerminated;
this.onRunStarted = options.onRunStarted;
this.onRunCompleted = options.onRunCompleted;
this.taskStore = options.taskStore;
this.rootDir = options.rootDir;
this.messageStore = options.messageStore;
}
/**
@@ -1060,6 +1071,7 @@ export class HeartbeatMonitor {
// Lazy-load createFnAgent and promptWithFallback
const { createFnAgent, promptWithFallback } = await import("./pi.js");
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { buildSessionSkillContextSync } = await import("./session-skill-context.js");
// Build tools with task creation tracking and run context for mutation correlation
@@ -1135,7 +1147,9 @@ export class HeartbeatMonitor {
}
// Create agent session
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
pluginRunner: this.pluginRunner,
cwd: rootDir,
systemPrompt,
tools: "readonly",

View File

@@ -0,0 +1,116 @@
/**
* Agent runtime adapter abstraction layer.
*
* Provides a typed interface for creating and managing agent sessions
* across different runtime implementations (default pi runtime, plugin-provided runtimes).
*
* ## Interface Contract
*
* All runtimes must implement:
* - `id`: Unique runtime identifier (e.g., "pi", "code-interpreter")
* - `name`: Human-readable name
* - `createSession()`: Create a new agent session
* - `promptWithFallback()`: Prompt with automatic retry/compaction
* - `describeModel()`: Get model description from session
*/
import type { AgentSession, SessionManager, ToolDefinition } from "@mariozechner/pi-coding-agent";
import type { SkillSelectionContext } from "./skill-resolver.js";
/**
* Options for creating an agent session.
* Mirrors the options accepted by createFnAgent.
*/
export interface AgentRuntimeOptions {
/** Working directory for the agent session */
cwd: string;
/** System prompt for the agent */
systemPrompt: string;
/** Tool set to use: "coding" for full tools, "readonly" for read-only access */
tools?: "coding" | "readonly";
/** Additional custom tools to merge with the base toolset */
customTools?: ToolDefinition[];
/** Callback for text output from the agent */
onText?: (delta: string) => void;
/** Callback for thinking/thought output from the agent */
onThinking?: (delta: string) => void;
/** Callback when a tool starts execution */
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
/** Callback when a tool finishes execution */
onToolEnd?: (name: string, isError: boolean, result?: unknown) => void;
/** Default model provider (e.g. "anthropic") */
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5") */
defaultModelId?: string;
/** Optional fallback model provider for retryable errors */
fallbackProvider?: string;
/** Optional fallback model ID */
fallbackModelId?: string;
/** Default thinking effort level (e.g. "medium", "high") */
defaultThinkingLevel?: string;
/** Optional pre-configured SessionManager for persistence */
sessionManager?: SessionManager;
/** Optional skill selection context */
skillSelection?: SkillSelectionContext;
/** Convenience: skill names to include in the session */
skills?: string[];
}
/**
* Result of creating an agent session.
*/
export interface AgentSessionResult {
/** The created agent session */
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions) */
sessionFile?: string;
}
/**
* Agent runtime adapter interface.
*
* All session runtimes (default pi runtime, plugin-provided runtimes) must
* implement this interface to ensure consistent behavior across engine subsystems.
*
* ## Implementation Notes
*
* - `createSession()` should return a fully initialized session ready for prompting
* - `promptWithFallback()` should handle retry/compaction automatically
* - `describeModel()` should return a human-readable model identifier
*/
export interface AgentRuntime {
/** Unique runtime identifier (e.g., "pi", "code-interpreter", "web-search") */
readonly id: string;
/** Human-readable name for the runtime */
readonly name: string;
/**
* Create a new agent session.
*
* @param options - Session creation options
* @returns Promise resolving to the session result
*/
createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult>;
/**
* Prompt the session with user input.
*
* Implementations should handle:
* - Automatic retry on transient errors
* - Context compaction on context limit errors
* - Model fallback on retryable model selection errors
*
* @param session - The session to prompt
* @param prompt - The prompt text
* @param options - Optional prompt options (e.g., images for vision)
*/
promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
/**
* Get a human-readable model description from a session.
*
* @param session - The session to describe
* @returns Model description (e.g., "anthropic/claude-sonnet-4-5") or "unknown model"
*/
describeModel(session: AgentSession): string;
}

View File

@@ -0,0 +1,111 @@
/**
* Helper functions for creating agent sessions with runtime resolution.
*
* These helpers wrap the runtime resolution pattern so that subsystems
* don't need to duplicate the resolution logic. They use the resolver
* to select the appropriate runtime and then delegate to it for session
* creation and prompting.
*/
import type { AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { resolveRuntime, buildRuntimeResolutionContext, type SessionPurpose } from "./runtime-resolution.js";
import { createLogger } from "./logger.js";
/** Logger for agent session helpers */
const sessionLog = createLogger("agent-session");
/**
* Options for creating an agent session with runtime resolution.
*/
export interface ResolvedSessionOptions extends AgentRuntimeOptions {
/** Session purpose for runtime selection */
sessionPurpose: SessionPurpose;
/** Plugin runner for runtime lookup. When provided, enables plugin runtime selection. */
pluginRunner?: PluginRunner;
/** Optional runtime hint from task/agent configuration */
runtimeHint?: string;
}
/**
* Result of creating an agent session with runtime resolution.
*/
export interface ResolvedSessionResult {
/** The created agent session */
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions) */
sessionFile?: string;
/** The runtime ID that was used */
runtimeId: string;
/** Whether the runtime was explicitly configured */
wasConfigured: boolean;
}
/**
* Create an agent session using runtime resolution.
*
* This function:
* 1. Resolves the appropriate runtime based on sessionPurpose, runtimeHint, and pluginRunner
* 2. Creates the session using the resolved runtime
* 3. Returns the session along with metadata about which runtime was used
*
* @param options - Session creation options including purpose and runtime configuration
* @returns Promise resolving to the session result with runtime metadata
*/
export async function createResolvedAgentSession(
options: ResolvedSessionOptions,
): Promise<ResolvedSessionResult> {
const { sessionPurpose, pluginRunner, runtimeHint, ...runtimeOptions } = options;
// Build the resolution context
const context = buildRuntimeResolutionContext(sessionPurpose, pluginRunner, runtimeHint);
// Resolve the runtime
const resolved = await resolveRuntime(context);
sessionLog.log(
`[${sessionPurpose}] Using runtime "${resolved.runtimeId}" (configured=${resolved.wasConfigured})`,
);
// Create the session using the resolved runtime
const result = await resolved.runtime.createSession(runtimeOptions);
return {
session: result.session,
sessionFile: result.sessionFile,
runtimeId: resolved.runtimeId,
wasConfigured: resolved.wasConfigured,
};
}
/**
* Prompt an agent session with automatic retry and compaction.
*
* This is a convenience wrapper that delegates to the runtime's promptWithFallback.
*
* @param session - The session to prompt
* @param prompt - The prompt text
* @param options - Optional prompt options (e.g., images)
*/
export async function promptWithAutoRetry(
session: AgentSession,
prompt: string,
options?: unknown,
): Promise<void> {
// Dynamic import to get the default runtime's promptWithFallback
// This works because the default runtime delegates to the existing implementation
const { promptWithFallback: pwf } = await import("./pi.js");
return pwf(session, prompt, options);
}
/**
* Get a human-readable model description from a session.
*
* @param session - The session to describe
* @returns Model description string
*/
export async function describeAgentModel(session: AgentSession): Promise<string> {
const { describeModel: dm } = await import("./pi.js");
return dm(session);
}

View File

@@ -49,6 +49,20 @@ vi.mock("./merger.js", () => ({
aiMergeTask: vi.fn(),
findWorktreeUser: vi.fn().mockResolvedValue(null),
}));
vi.mock("./agent-session-helpers.js", async () => {
const { createFnAgent } = await import("./pi.js");
return {
createResolvedAgentSession: async (options: any) => {
const result = await createFnAgent(options);
return {
session: result.session,
sessionFile: result.sessionFile,
runtimeId: "pi",
wasConfigured: false,
};
},
};
});
vi.mock("./worktree-names.js", async () => {
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
return {

View File

@@ -10,7 +10,8 @@ import { buildExecutionMemoryInstructions, getTaskMergeBlocker, resolveAgentProm
import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createFnAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { createResolvedAgentSession, describeAgentModel } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { ModelRegistry, SessionManager, type ToolDefinition, type AgentSession } from "@mariozechner/pi-coding-agent";
@@ -1765,7 +1766,9 @@ export class TaskExecutor {
// sessionFile must be let because it's destructured alongside session which is reassigned
// eslint-disable-next-line prefer-const
let { session, sessionFile } = await createFnAgent({
let { session, sessionFile } = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: executorSystemPrompt,
tools: "coding",
@@ -2000,7 +2003,9 @@ export class TaskExecutor {
this.activeSessions.delete(task.id);
session.dispose();
const { session: retrySession, sessionFile: retrySessionFile } = await createFnAgent({
const { session: retrySession, sessionFile: retrySessionFile } = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: executorSystemPrompt,
tools: "coding",
@@ -3627,7 +3632,9 @@ and show an appropriate message to the user.\`
projectRootDir: this.rootDir,
});
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: this.options.pluginRunner,
cwd: worktreePath,
systemPrompt: stepSystemPrompt,
tools: toolMode,
@@ -4673,7 +4680,9 @@ and show an appropriate message to the user.\`
});
// Create child agent session
const { session: childSession } = await createFnAgent({
const { session: childSession } = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: this.options.pluginRunner,
cwd: childWorktreePath,
systemPrompt: childSystemPrompt,
tools: "coding",

View File

@@ -47,6 +47,24 @@ export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from ".
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js";
export { PluginRunner, type PluginRunnerOptions } from "./plugin-runner.js";
// Agent runtime abstraction
export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js";
export {
resolveRuntime,
getDefaultPiRuntime,
buildRuntimeResolutionContext,
type RuntimeResolutionContext,
type ResolvedRuntime,
type SessionPurpose,
} from "./runtime-resolution.js";
// Agent session helpers
export {
createResolvedAgentSession,
promptWithAutoRetry,
describeAgentModel,
type ResolvedSessionOptions,
type ResolvedSessionResult,
} from "./agent-session-helpers.js";
export { ProjectManager } from "./project-manager.js";
export { ProjectEngine, type ProjectEngineOptions } from "./project-engine.js";
export { ProjectEngineManager, type EngineManagerOptions } from "./project-engine-manager.js";

View File

@@ -7,7 +7,8 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type MergeResult, type MergeDetails, type WorkflowStep, type WorkflowStepResult, type Settings, type AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import { createFnAgent, describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
@@ -662,7 +663,9 @@ async function attemptInMergeVerificationFix(
}
// Create the fix agent session
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
cwd: rootDir, // Runs on the main branch in the project root
systemPrompt: `You are a verification fix agent running during a merge on the main branch.
@@ -1332,6 +1335,8 @@ export interface MergerOptions {
onSession?: (session: { dispose: () => void }) => void;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
}
function quoteArg(value: string): string {
@@ -1395,7 +1400,7 @@ async function resolveComplexRebaseConflictsWithAi(
taskId: string,
settings: Settings,
conflictedFiles: string[],
options?: { onAgentText?: (delta: string) => void },
options?: { onAgentText?: (delta: string) => void; pluginRunner?: import("./plugin-runner.js").PluginRunner },
): Promise<void> {
mergerLog.log(`${taskId}: resolving ${conflictedFiles.length} complex rebase conflict(s) with AI`);
@@ -1420,7 +1425,9 @@ You are assisting with a paused \`git pull --rebase\`.
: undefined,
});
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options?.pluginRunner,
cwd: rootDir,
systemPrompt,
tools: "coding",
@@ -2765,7 +2772,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
}
}
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: options.pluginRunner,
cwd: rootDir,
systemPrompt: mergerSystemPrompt,
tools: "coding",
@@ -3243,7 +3252,9 @@ If issues are found that need attention, describe them clearly.`;
}
}
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "merger",
pluginRunner: mergeOptions.pluginRunner,
cwd,
systemPrompt: postMergeSystemPrompt,
tools: toolMode,

View File

@@ -20,6 +20,7 @@ import type {
MissionValidatorRun,
} from "@fusion/core";
import { createFnAgent, promptWithFallback, type AgentResult } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { createLogger } from "./logger.js";
/** Logger for the mission execution loop subsystem. */
@@ -63,6 +64,8 @@ export interface MissionExecutionLoopOptions {
rootDir: string;
/** Maximum implementation retry budget (default: 3) */
maxRetryBudget?: number;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
}
export class MissionExecutionLoop extends EventEmitter {
@@ -72,6 +75,7 @@ export class MissionExecutionLoop extends EventEmitter {
private rootDir: string;
private maxRetryBudget: number;
private missionAutopilot?: MissionExecutionLoopOptions["missionAutopilot"];
private pluginRunner?: MissionExecutionLoopOptions["pluginRunner"];
private activeValidations = new Set<string>(); // feature IDs currently being validated
constructor(options: MissionExecutionLoopOptions) {
@@ -81,6 +85,7 @@ export class MissionExecutionLoop extends EventEmitter {
this.rootDir = options.rootDir;
this.maxRetryBudget = options.maxRetryBudget ?? 3;
this.missionAutopilot = options.missionAutopilot;
this.pluginRunner = options.pluginRunner;
loopLog.log("MissionExecutionLoop created");
}
@@ -325,7 +330,9 @@ export class MissionExecutionLoop extends EventEmitter {
try {
// Create validation agent session
session = await createFnAgent({
const sessionResult = await createResolvedAgentSession({
sessionPurpose: "validation",
pluginRunner: this.pluginRunner,
cwd: this.rootDir,
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext),
tools: "readonly",
@@ -334,6 +341,7 @@ export class MissionExecutionLoop extends EventEmitter {
// Could stream this to a log entry if needed
},
});
session = { session: sessionResult.session, sessionFile: sessionResult.sessionFile };
loopLog.log(`Validation session created for feature ${feature.id}`);

View File

@@ -738,6 +738,90 @@ describe("PluginRunner", () => {
});
});
describe("getRuntimeById()", () => {
it("should return undefined when no runtimes exist", () => {
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
const result = pluginRunner.getRuntimeById("code-interpreter");
expect(result).toBeUndefined();
});
it("should return the runtime when runtimeId matches", () => {
const mockRuntime = {
metadata: {
runtimeId: "code-interpreter",
name: "Code Interpreter",
},
factory: vi.fn(),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "code-plugin", runtime: mockRuntime as any },
]);
const result = pluginRunner.getRuntimeById("code-interpreter");
expect(result).toEqual({ pluginId: "code-plugin", runtime: mockRuntime });
});
it("should return undefined when runtimeId does not match", () => {
const mockRuntime = {
metadata: {
runtimeId: "web-search",
name: "Web Search",
},
factory: vi.fn(),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "search-plugin", runtime: mockRuntime as any },
]);
const result = pluginRunner.getRuntimeById("code-interpreter");
expect(result).toBeUndefined();
});
it("should return first matching runtime when multiple plugins have same runtimeId", () => {
const mockRuntime1 = {
metadata: {
runtimeId: "shared-id",
name: "First Runtime",
},
factory: vi.fn(),
};
const mockRuntime2 = {
metadata: {
runtimeId: "shared-id",
name: "Second Runtime",
},
factory: vi.fn(),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "plugin-1", runtime: mockRuntime1 as any },
{ pluginId: "plugin-2", runtime: mockRuntime2 as any },
]);
const result = pluginRunner.getRuntimeById("shared-id");
expect(result?.pluginId).toBe("plugin-1");
});
it("should find runtime even when cache is already built", () => {
const mockRuntime = {
metadata: {
runtimeId: "cached-runtime",
name: "Cached Runtime",
},
factory: vi.fn(),
};
mockPluginLoader.getPluginRuntimes.mockReturnValue([
{ pluginId: "cached-plugin", runtime: mockRuntime as any },
]);
// First call builds cache
pluginRunner.getRuntimeById("other-id");
// Second call should find from cache
const result = pluginRunner.getRuntimeById("cached-runtime");
expect(result?.pluginId).toBe("cached-plugin");
expect(mockPluginLoader.getPluginRuntimes).toHaveBeenCalledTimes(1);
});
});
describe("getLoader() / getStore()", () => {
it("should return the plugin loader", () => {
const loader = pluginRunner.getLoader();

View File

@@ -234,6 +234,17 @@ export class PluginRunner {
return this.cachedRuntimes.runtimes;
}
/**
* Get a specific runtime registration by its runtimeId.
*
* @param runtimeId - The unique runtime identifier to find
* @returns The runtime registration with plugin ID, or undefined if not found
*/
getRuntimeById(runtimeId: string): { pluginId: string; runtime: PluginRuntimeRegistration } | undefined {
const registrations = this.getPluginRuntimes();
return registrations.find((reg) => reg.runtime.metadata.runtimeId === runtimeId);
}
/**
* Get the underlying plugin loader.
*/
@@ -481,6 +492,35 @@ export class PluginRunner {
};
}
/**
* Create a plugin context for runtime instantiation.
*
* This context is passed to plugin runtime factories to allow them
* to initialize their runtime instances with access to task store,
* settings, and logging.
*
* @param pluginId - The plugin ID to create context for
* @returns The plugin context, or null if the plugin is not loaded
*/
async createRuntimeContext(pluginId: string): Promise<PluginContext | null> {
const plugin = this.options.pluginLoader.getPlugin(pluginId);
if (!plugin) {
this.log.warn(`Plugin "${pluginId}" not loaded, cannot create runtime context`);
return null;
}
const settings = await this.getPluginSettings(pluginId);
return {
pluginId,
taskStore: this.options.taskStore,
settings,
logger: this.createPluginLogger(pluginId),
emitEvent: (event: string, data: unknown) => {
this.log.log(`[plugin:${pluginId}] Event: ${event}`, data);
},
};
}
/**
* Get settings for a plugin from the store.
*/

View File

@@ -30,6 +30,20 @@ vi.mock("./pi.js", () => ({
vi.mock("./reviewer.js", () => ({
reviewStep: vi.fn(),
}));
vi.mock("./agent-session-helpers.js", async () => {
const { createFnAgent } = await import("./pi.js");
return {
createResolvedAgentSession: async (options: any) => {
const result = await createFnAgent(options);
return {
session: result.session,
sessionFile: result.sessionFile,
runtimeId: "pi",
wasConfigured: false,
};
},
};
});
vi.mock("node:child_process", () => {
const { promisify } = require("node:util");
const execSyncFn = vi.fn().mockReturnValue(Buffer.from(""));

View File

@@ -10,7 +10,8 @@
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core";
import { createFnAgent, describeModel, promptWithFallback } from "./pi.js";
import { describeModel, promptWithFallback } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js";
@@ -230,6 +231,8 @@ export interface ReviewOptions {
rootDir?: string;
/** Project settings used for backend-aware memory tools and instructions. */
settings?: Settings;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
}
/**
@@ -359,7 +362,9 @@ export async function reviewStep(
} : undefined),
]
: undefined;
const { session } = await createFnAgent({
const { session } = await createResolvedAgentSession({
sessionPurpose: "reviewer",
pluginRunner: options.pluginRunner,
cwd,
systemPrompt: reviewerSystemPrompt,
tools: "readonly",

View File

@@ -0,0 +1,332 @@
/**
* Runtime Resolution Tests
*
* Tests for the runtime resolution system including:
* - Default pi runtime selection
* - Plugin runtime lookup by hint
* - Fallback behavior when configured runtime is unavailable
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
import {
resolveRuntime,
getDefaultPiRuntime,
type RuntimeResolutionContext,
type ResolvedRuntime,
} from "./runtime-resolution.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { PluginRuntimeRegistration } from "@fusion/core";
// Mock the logger to suppress output during tests
vi.mock("./logger.js", () => ({
createLogger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
// Mock pi.js to avoid actual session creation
vi.mock("./pi.js", () => ({
createFnAgent: vi.fn().mockResolvedValue({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
prompt: vi.fn(),
},
sessionFile: undefined,
}),
promptWithFallback: vi.fn().mockResolvedValue(undefined),
describeModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
}));
describe("runtime-resolution", () => {
let mockPluginRunner: {
getPluginRuntimes: ReturnType<typeof vi.fn>;
getRuntimeById: ReturnType<typeof vi.fn>;
createRuntimeContext: ReturnType<typeof vi.fn>;
};
const createMockPluginRuntime = (runtimeId: string, name: string): PluginRuntimeRegistration => ({
metadata: {
runtimeId,
name,
description: `Test runtime ${runtimeId}`,
version: "1.0.0",
},
factory: vi.fn().mockResolvedValue({
id: runtimeId,
name,
createSession: vi.fn().mockResolvedValue({
session: { model: { provider: "test", id: "test-model" } },
}),
promptWithFallback: vi.fn(),
describeModel: vi.fn().mockReturnValue("test/model"),
}),
});
beforeEach(() => {
mockPluginRunner = {
getPluginRuntimes: vi.fn().mockReturnValue([]),
getRuntimeById: vi.fn().mockReturnValue(undefined),
createRuntimeContext: vi.fn().mockResolvedValue({
pluginId: "test-plugin",
taskStore: {},
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
}),
};
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getDefaultPiRuntime()", () => {
it("should return the same instance on multiple calls", () => {
const runtime1 = getDefaultPiRuntime();
const runtime2 = getDefaultPiRuntime();
expect(runtime1).toBe(runtime2);
});
it("should have pi as the runtime id", () => {
const runtime = getDefaultPiRuntime();
expect(runtime.id).toBe("pi");
});
it("should have a human-readable name", () => {
const runtime = getDefaultPiRuntime();
expect(runtime.name).toBe("Default PI Runtime");
});
it("should implement createSession", () => {
const runtime = getDefaultPiRuntime();
expect(typeof runtime.createSession).toBe("function");
});
it("should implement promptWithFallback", () => {
const runtime = getDefaultPiRuntime();
expect(typeof runtime.promptWithFallback).toBe("function");
});
it("should implement describeModel", () => {
const runtime = getDefaultPiRuntime();
expect(typeof runtime.describeModel).toBe("function");
});
});
describe("resolveRuntime()", () => {
const createContext = (
purpose: RuntimeResolutionContext["sessionPurpose"] = "executor",
runtimeHint?: string,
): RuntimeResolutionContext => ({
sessionPurpose: purpose,
runtimeHint,
pluginRunner: mockPluginRunner as unknown as PluginRunner,
});
describe("no runtime hint", () => {
it("should return default pi runtime when hint is undefined", async () => {
const context = createContext("executor", undefined);
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
expect(result.runtime.id).toBe("pi");
});
it("should return default pi runtime when hint is empty string", async () => {
const context = createContext("executor", "");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should return default pi runtime when hint is whitespace only", async () => {
const context = createContext("executor", " ");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should work for all session purposes without hint", async () => {
const purposes: RuntimeResolutionContext["sessionPurpose"][] = [
"executor",
"triage",
"reviewer",
"merger",
"heartbeat",
"validation",
];
for (const purpose of purposes) {
const context = createContext(purpose, undefined);
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
}
});
});
describe("explicit pi hint", () => {
it("should return pi runtime when hint is 'pi'", async () => {
const context = createContext("executor", "pi");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(true);
});
it("should return pi runtime when hint is 'default'", async () => {
const context = createContext("executor", "default");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(true);
});
});
describe("plugin runtime lookup", () => {
it("should return plugin runtime when hint matches existing runtime", async () => {
const mockRuntime = createMockPluginRuntime("code-interpreter", "Code Interpreter Runtime");
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "test-plugin",
runtime: mockRuntime,
});
const context = createContext("executor", "code-interpreter");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("code-interpreter");
expect(result.wasConfigured).toBe(true);
expect(result.runtime.id).toBe("code-interpreter");
});
it("should create runtime context when resolving plugin runtime", async () => {
const mockRuntime = createMockPluginRuntime("web-search", "Web Search Runtime");
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "web-plugin",
runtime: mockRuntime,
});
const context = createContext("executor", "web-search");
await resolveRuntime(context);
expect(mockPluginRunner.createRuntimeContext).toHaveBeenCalledWith("web-plugin");
});
it("should call the plugin runtime factory", async () => {
const mockRuntime = createMockPluginRuntime("custom", "Custom Runtime");
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "custom-plugin",
runtime: mockRuntime,
});
const context = createContext("executor", "custom");
await resolveRuntime(context);
expect(mockRuntime.factory).toHaveBeenCalled();
});
it("should find runtime by ID when multiple plugins have runtimes", async () => {
const mockRuntime = createMockPluginRuntime("unique-id", "First Runtime");
// getRuntimeById returns the first match
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "plugin-1",
runtime: mockRuntime,
});
const context = createContext("executor", "unique-id");
const result = await resolveRuntime(context);
// Should return the matching runtime
expect(result.runtime.id).toBe("unique-id");
});
});
describe("fallback behavior", () => {
it("should fall back to pi when runtime hint references non-existent runtime", async () => {
// getRuntimeById returns undefined for non-existent runtime
mockPluginRunner.getRuntimeById.mockReturnValue(undefined);
const context = createContext("executor", "non-existent");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should fall back to pi when runtime factory throws", async () => {
const mockRuntime: PluginRuntimeRegistration = {
metadata: {
runtimeId: "broken",
name: "Broken Runtime",
},
factory: vi.fn().mockRejectedValue(new Error("Factory failed")),
};
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "broken-plugin",
runtime: mockRuntime,
});
const context = createContext("executor", "broken");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should fall back to pi when runtime factory returns null", async () => {
const mockRuntime: PluginRuntimeRegistration = {
metadata: {
runtimeId: "null-return",
name: "Null Return Runtime",
},
factory: vi.fn().mockResolvedValue(null),
};
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "null-plugin",
runtime: mockRuntime,
});
const context = createContext("executor", "null-return");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should fall back to pi when createRuntimeContext returns null", async () => {
mockPluginRunner.createRuntimeContext.mockResolvedValue(null);
const mockRuntime = createMockPluginRuntime("orphan", "Orphan Runtime");
mockPluginRunner.getRuntimeById.mockReturnValue({
pluginId: "orphan-plugin",
runtime: mockRuntime,
});
const context = createContext("executor", "orphan");
const result = await resolveRuntime(context);
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
});
});
describe("AgentRuntime interface compliance", () => {
it("default pi runtime should implement all required interface methods", () => {
const runtime = getDefaultPiRuntime();
// Check required properties
expect(typeof runtime.id).toBe("string");
expect(typeof runtime.name).toBe("string");
// Check required methods
expect(typeof runtime.createSession).toBe("function");
expect(typeof runtime.promptWithFallback).toBe("function");
expect(typeof runtime.describeModel).toBe("function");
});
});
});

View File

@@ -0,0 +1,365 @@
/**
* Runtime resolution utilities for selecting and instantiating agent runtimes.
*
* Provides a resolution layer that:
* 1. Looks up plugin-provided runtimes when a runtime hint is configured
* 2. Falls back to the default pi runtime when no hint is provided or lookup fails
* 3. Provides structured logging for debugging runtime selection decisions
*/
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { createLogger } from "./logger.js";
/** Logger for the runtime resolution subsystem */
const runtimeLog = createLogger("runtime-resolver");
/**
* Session purpose for runtime selection context.
* Determines which runtime selection rules apply.
*/
export type SessionPurpose =
| "executor"
| "triage"
| "reviewer"
| "merger"
| "heartbeat"
| "validation";
/**
* Context for runtime resolution.
* Provides all information needed to select and configure a runtime.
*/
export interface RuntimeResolutionContext {
/** Purpose of the session (affects runtime selection behavior) */
sessionPurpose: SessionPurpose;
/** Optional runtime hint (runtimeId) from task/agent configuration.
* When provided and non-empty, the resolver attempts to find a matching
* plugin runtime before falling back to the default. */
runtimeHint?: string;
/** PluginRunner for looking up plugin-provided runtimes */
pluginRunner: PluginRunner;
}
/**
* Result of runtime resolution.
*/
export interface ResolvedRuntime {
/** The resolved runtime instance */
runtime: AgentRuntime;
/** Whether this runtime was explicitly configured via hint (vs. default) */
wasConfigured: boolean;
/** The runtime ID that was resolved */
runtimeId: string;
}
/**
* Reason for fallback when configured runtime is unavailable.
*/
export type FallbackReason =
/** Runtime hint was provided but no matching runtime was found */
| "not_found"
/** Runtime factory function threw an error during instantiation */
| "factory_error"
/** Runtime was found but failed to initialize */
| "init_error";
/**
* Default pi-based runtime implementation.
*
* This runtime wraps the existing createFnAgent + promptWithFallback
* implementation without any behavior changes. It serves as:
* 1. The default runtime when no runtime hint is configured
* 2. The fallback runtime when a configured plugin runtime is unavailable
*/
export class DefaultPiRuntime implements AgentRuntime {
readonly id = "pi";
readonly name = "Default PI Runtime";
// Synchronous cached describeModel function
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static describeModelFn: ((session: AgentSession) => string) | null = null;
/**
* Create an agent session using the default pi implementation.
*/
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
const { createFnAgent } = await import("./pi.js");
return createFnAgent(options);
}
/**
* Prompt with automatic retry and compaction.
* Delegates to the existing promptWithFallback implementation.
*/
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
const { promptWithFallback: pwf } = await import("./pi.js");
return pwf(session, prompt, options);
}
/**
* Get model description from session.
*/
describeModel(session: AgentSession): string {
if (!DefaultPiRuntime.describeModelFn) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { describeModel } = require("./pi.js");
DefaultPiRuntime.describeModelFn = describeModel;
}
return DefaultPiRuntime.describeModelFn!(session);
}
}
/**
* Singleton instance of the default pi runtime.
* Reused across all resolution requests.
*/
let defaultPiRuntimeInstance: DefaultPiRuntime | null = null;
/**
* Get the singleton default pi runtime instance.
*/
export function getDefaultPiRuntime(): AgentRuntime {
if (!defaultPiRuntimeInstance) {
defaultPiRuntimeInstance = new DefaultPiRuntime();
}
return defaultPiRuntimeInstance;
}
/**
* Resolve a plugin runtime by its runtimeId.
*
* @param pluginRunner - PluginRunner for looking up runtimes
* @param runtimeId - The runtime ID to find
* @returns The resolved runtime wrapper, or null if not found
*/
async function resolvePluginRuntime(
pluginRunner: PluginRunner,
runtimeId: string,
): Promise<{ runtime: AgentRuntime; pluginId: string } | null> {
// Use the convenience method for single runtime lookup
const registration = pluginRunner.getRuntimeById(runtimeId);
if (!registration) {
return null;
}
const { pluginId, runtime } = registration;
runtimeLog.log(`Found plugin runtime "${runtimeId}" from plugin "${pluginId}"`);
try {
// Create plugin context for runtime factory
const pluginContext = await pluginRunner.createRuntimeContext(pluginId);
if (!pluginContext) {
runtimeLog.warn(`Plugin "${pluginId}" runtime factory context unavailable`);
return null;
}
// Instantiate the runtime via factory
const factoryResult = runtime.factory(pluginContext);
const instance = await (factoryResult instanceof Promise ? factoryResult : Promise.resolve(factoryResult));
if (!instance) {
runtimeLog.warn(`Plugin "${pluginId}" runtime factory returned null`);
return null;
}
// Wrap the plugin runtime to conform to AgentRuntime interface
// The plugin may return its own interface, so we adapt if needed
const wrappedRuntime = wrapPluginRuntime(instance, runtime.metadata.runtimeId, runtime.metadata.name);
return { runtime: wrappedRuntime, pluginId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeLog.error(`Plugin "${pluginId}" runtime factory error: ${message}`);
return null;
}
}
/**
* Wrap a plugin runtime instance to conform to the AgentRuntime interface.
*
* Plugin runtimes may return their own interface types. This function
* adapts them to the standard AgentRuntime interface.
*/
function wrapPluginRuntime(
instance: unknown,
runtimeId: string,
runtimeName: string,
): AgentRuntime {
// If it's already an AgentRuntime, return as-is
if (isAgentRuntime(instance)) {
return instance;
}
// Otherwise, wrap in a compatibility layer
// The plugin should return something compatible with our interface
// but we provide a defensive fallback
runtimeLog.warn(`Plugin runtime "${runtimeId}" does not conform to AgentRuntime interface, wrapping with adapter`);
return {
id: runtimeId,
name: runtimeName,
createSession: async (options: AgentRuntimeOptions) => {
const adapter = instance as Record<string, unknown>;
if (typeof adapter.createSession === "function") {
const result = await adapter.createSession(options);
return {
session: (result as AgentSessionResult).session ?? (result as AgentSession),
sessionFile: (result as AgentSessionResult).sessionFile,
};
}
throw new Error(`Plugin runtime "${runtimeId}" does not implement createSession`);
},
promptWithFallback: async (session: AgentSession, prompt: string, options?: unknown) => {
const adapter = instance as Record<string, unknown>;
if (typeof adapter.promptWithFallback === "function") {
return adapter.promptWithFallback(session, prompt, options);
}
// Fallback to default pi promptWithFallback
const { promptWithFallback: pwf } = await import("./pi.js");
return pwf(session, prompt, options);
},
describeModel: (session: AgentSession) => {
const adapter = instance as Record<string, unknown>;
if (typeof adapter.describeModel === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (adapter.describeModel as (s: AgentSession) => string)(session);
}
// Fallback to default pi describeModel - use cached sync function
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { describeModel: dm } = require("./pi.js");
return dm(session);
},
};
}
/**
* Type guard to check if an object conforms to AgentRuntime.
*/
function isAgentRuntime(obj: unknown): obj is AgentRuntime {
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"name" in obj &&
typeof (obj as AgentRuntime).createSession === "function" &&
typeof (obj as AgentRuntime).promptWithFallback === "function" &&
typeof (obj as AgentRuntime).describeModel === "function"
);
}
/**
* Resolve an agent runtime based on the resolution context.
*
* Resolution algorithm:
* 1. If runtimeHint is provided and non-empty:
* a. Look up runtime by ID from plugin runner
* b. If found: instantiate and return with wasConfigured=true
* c. If not found: log structured warning, fall back to pi runtime
* 2. If no runtimeHint:
* a. Return the default pi runtime with wasConfigured=false
*
* @param context - Resolution context with purpose, hint, and plugin runner
* @returns The resolved runtime with metadata about how it was selected
*/
export async function resolveRuntime(context: RuntimeResolutionContext): Promise<ResolvedRuntime> {
const { sessionPurpose, runtimeHint, pluginRunner } = context;
// Case 1: No runtime hint provided — use default pi runtime
if (!runtimeHint || runtimeHint.trim() === "") {
runtimeLog.log(`[${sessionPurpose}] No runtime hint configured, using default pi runtime`);
return {
runtime: getDefaultPiRuntime(),
wasConfigured: false,
runtimeId: "pi",
};
}
// Case 2: Runtime hint provided — try to find matching plugin runtime
const runtimeId = runtimeHint.trim();
// Check if the hint is explicitly "pi" — use default runtime
if (runtimeId === "pi" || runtimeId === "default") {
runtimeLog.log(`[${sessionPurpose}] Runtime hint is "pi/default", using default pi runtime`);
return {
runtime: getDefaultPiRuntime(),
wasConfigured: true,
runtimeId: "pi",
};
}
// Look up the plugin runtime
try {
const resolved = await resolvePluginRuntime(pluginRunner, runtimeId);
if (resolved) {
runtimeLog.log(`[${sessionPurpose}] Using configured plugin runtime "${runtimeId}" from "${resolved.pluginId}"`);
return {
runtime: resolved.runtime,
wasConfigured: true,
runtimeId,
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeLog.error(`[${sessionPurpose}] Error resolving plugin runtime "${runtimeId}": ${message}`);
}
// Case 3: Runtime not found or error — fall back to pi with warning
logRuntimeFallback(sessionPurpose, runtimeId, "not_found");
return {
runtime: getDefaultPiRuntime(),
wasConfigured: false,
runtimeId: "pi",
};
}
/**
* Log structured fallback warning when configured runtime is unavailable.
*/
function logRuntimeFallback(
sessionPurpose: SessionPurpose,
requestedRuntimeId: string,
reason: FallbackReason,
): void {
runtimeLog.warn(
`[${sessionPurpose}] Runtime "${requestedRuntimeId}" unavailable (${reason}), falling back to default pi runtime`,
);
}
/**
* Build a RuntimeResolutionContext with a default plugin runner.
*
* When a subsystem has a pluginRunner, use this helper to create the context
* with sensible defaults. The runtimeHint should come from the task/agent
* configuration when available.
*
* @param sessionPurpose - The purpose of the session
* @param pluginRunner - The plugin runner for runtime lookup
* @param runtimeHint - Optional runtime hint from task/agent configuration
* @returns The resolution context
*/
export function buildRuntimeResolutionContext(
sessionPurpose: SessionPurpose,
pluginRunner: PluginRunner | undefined,
runtimeHint?: string,
): RuntimeResolutionContext {
return {
sessionPurpose,
runtimeHint,
pluginRunner: pluginRunner ?? createNoOpPluginRunner(),
};
}
/**
* Create a no-op plugin runner for subsystems that don't have access to the real one.
* This allows the resolver to still return the default pi runtime.
*/
function createNoOpPluginRunner(): PluginRunner {
return {
getPluginRuntimes: () => [],
getRuntimeById: () => undefined,
createRuntimeContext: async () => null,
} as unknown as PluginRunner;
}

View File

@@ -0,0 +1,273 @@
/**
* Runtime Selection Regression Tests
*
* These tests verify that engine subsystems correctly use the runtime resolution
* system and fall back to the default pi runtime when no runtime hint is configured.
*
* Key behaviors tested:
* 1. Subsystems with pluginRunner option can resolve plugin runtimes
* 2. Subsystems without pluginRunner or hint fall back to pi runtime
* 3. Runtime resolution logs are emitted correctly
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the logger to suppress output during tests
vi.mock("./logger.js", () => ({
createLogger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
// Mock pi.js to avoid actual session creation
vi.mock("./pi.js", () => ({
createFnAgent: vi.fn().mockResolvedValue({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
prompt: vi.fn(),
},
sessionFile: undefined,
}),
promptWithFallback: vi.fn().mockResolvedValue(undefined),
describeModel: vi.fn().mockReturnValue("anthropic/claude-sonnet-4-5"),
}));
// Mock the runtime resolution module
const mockResolveRuntime = vi.fn();
vi.mock("./runtime-resolution.js", () => ({
resolveRuntime: (...args: unknown[]) => mockResolveRuntime(...args),
buildRuntimeResolutionContext: vi.fn().mockReturnValue({
sessionPurpose: "test",
runtimeHint: undefined,
pluginRunner: {},
}),
}));
// Mock session skill context
vi.mock("./session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn().mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
skillSource: "none",
}),
buildSessionSkillContextSync: vi.fn().mockReturnValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
skillSource: "none",
}),
}));
// Mock agent instructions
vi.mock("./agent-instructions.js", () => ({
resolveAgentInstructions: vi.fn().mockResolvedValue(""),
buildSystemPromptWithInstructions: vi.fn().mockImplementation((base) => base),
resolveAgentInstructionsWithRatings: vi.fn().mockResolvedValue(""),
}));
describe("Runtime Selection Regression Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: resolveRuntime returns pi runtime
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "pi",
name: "Default PI Runtime",
createSession: async () => ({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
prompt: vi.fn(),
},
}),
promptWithFallback: vi.fn(),
describeModel: () => "anthropic/claude-sonnet-4-5",
},
wasConfigured: false,
runtimeId: "pi",
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("createResolvedAgentSession", () => {
it("should call resolveRuntime when creating a session", async () => {
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: {} as any,
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(mockResolveRuntime).toHaveBeenCalled();
});
it("should use the resolved runtime's createSession method", async () => {
const mockCreateSession = vi.fn().mockResolvedValue({
session: {
model: { provider: "test", id: "test-model" },
},
});
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "test-runtime",
name: "Test Runtime",
createSession: mockCreateSession,
promptWithFallback: vi.fn(),
describeModel: () => "test/model",
},
wasConfigured: true,
runtimeId: "test-runtime",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: {} as any,
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(mockCreateSession).toHaveBeenCalled();
});
it("should return runtime metadata along with session", async () => {
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "my-runtime",
name: "My Runtime",
createSession: async () => ({
session: {
model: { provider: "test", id: "test-model" },
},
}),
promptWithFallback: vi.fn(),
describeModel: () => "test/model",
},
wasConfigured: true,
runtimeId: "my-runtime",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "triage",
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(result.runtimeId).toBe("my-runtime");
expect(result.wasConfigured).toBe(true);
});
});
describe("Default pi runtime fallback", () => {
it("should fall back to pi runtime when no runtime hint provided", async () => {
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "pi",
name: "Default PI Runtime",
createSession: async () => ({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
},
}),
promptWithFallback: vi.fn(),
describeModel: () => "anthropic/claude-sonnet-4-5",
},
wasConfigured: false,
runtimeId: "pi",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "merger",
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
it("should fall back to pi runtime when runtime hint references non-existent runtime", async () => {
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "pi",
name: "Default PI Runtime",
createSession: async () => ({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
},
}),
promptWithFallback: vi.fn(),
describeModel: () => "anthropic/claude-sonnet-4-5",
},
wasConfigured: false,
runtimeId: "pi",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
pluginRunner: {} as any,
runtimeHint: "non-existent-runtime",
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(result.runtimeId).toBe("pi");
expect(result.wasConfigured).toBe(false);
});
});
describe("Plugin runtime selection", () => {
it("should use configured plugin runtime when hint matches", async () => {
mockResolveRuntime.mockResolvedValue({
runtime: {
id: "code-interpreter",
name: "Code Interpreter Runtime",
createSession: async () => ({
session: {
model: { provider: "custom", id: "custom-model" },
},
}),
promptWithFallback: vi.fn(),
describeModel: () => "custom/model",
},
wasConfigured: true,
runtimeId: "code-interpreter",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: {
getRuntimeById: vi.fn().mockReturnValue({
pluginId: "code-plugin",
runtime: {
metadata: { runtimeId: "code-interpreter", name: "Code Interpreter" },
factory: vi.fn(),
},
}),
} as any,
runtimeHint: "code-interpreter",
cwd: "/test/path",
systemPrompt: "Test prompt",
});
expect(result.runtimeId).toBe("code-interpreter");
expect(result.wasConfigured).toBe(true);
});
});
});

View File

@@ -255,6 +255,7 @@ export class InProcessRuntime
}
: undefined,
rootDir: this.config.workingDirectory,
pluginRunner: this.pluginRunner,
})
: undefined;
@@ -452,6 +453,7 @@ export class InProcessRuntime
taskStore: this.taskStore,
rootDir: this.config.workingDirectory,
messageStore: this.messageStore,
pluginRunner: this.pluginRunner,
onMissed: (agentId) => {
runtimeLog.warn(`Agent ${agentId} missed heartbeat`);
},
@@ -596,6 +598,7 @@ export class InProcessRuntime
semaphore: this.globalSemaphore,
stuckTaskDetector: this.stuckTaskDetector,
agentStore: this.agentStore,
pluginRunner: this.pluginRunner,
onSpecifyStart: (t) => {
this.recordActivity();
runtimeLog.log(`Specifying ${t.id}...`);

View File

@@ -13,7 +13,8 @@ import type {
ToolDefinition,
AgentSession,
} from "@mariozechner/pi-coding-agent";
import { createFnAgent, describeModel, promptWithFallback } from "./pi.js";
import { describeModel, promptWithFallback } from "./pi.js";
import { createResolvedAgentSession } from "./agent-session-helpers.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
@@ -290,6 +291,8 @@ export interface TriageProcessorOptions {
onAgentText?: (taskId: string, delta: string) => void;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
}
/**
@@ -734,7 +737,9 @@ export class TriageProcessor {
projectRootDir: this.rootDir,
});
let { session } = await createFnAgent({
let { session } = await createResolvedAgentSession({
sessionPurpose: "triage",
pluginRunner: this.options.pluginRunner,
cwd: this.rootDir,
systemPrompt: triageSystemPrompt,
tools: "coding",
@@ -914,7 +919,9 @@ export class TriageProcessor {
specReviewVerdictRef.current = null;
approvedCommentFingerprintRef.current = "";
const fallbackResult = await createFnAgent({
const fallbackResult = await createResolvedAgentSession({
sessionPurpose: "triage",
pluginRunner: this.options.pluginRunner,
cwd: this.rootDir,
systemPrompt: triageSystemPrompt,
tools: "coding",