chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts

- Move all co-located *.test.* files into sibling __tests__/ directories so the
  layout is consistent across packages (159 renames + content-rewrite moves).
  Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
  path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
  skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
  accidentally committed in a merge and the stale pi.js was masking a real
  test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
  the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
  createFnAgent / promptWithFallback / describeModel from @fusion/engine
  instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
  the two plugin package.jsons that were missing it; exports describeModel
  from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
  ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
  .test.ts; switch three boundary-result toEqual assertions to toMatchObject
  so the new content/isError fields don't trip exact-match comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 18:45:10 -07:00
parent ab98cc3719
commit bce7dbd96f
232 changed files with 1311 additions and 26008 deletions

6
packages/engine/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Build outputs belong in dist/, not src/. Block accidental commits of tsc
# emit alongside .ts sources.
src/*.js
src/*.js.map
src/*.d.ts
src/*.d.ts.map

View File

@@ -10,13 +10,13 @@ import {
type HeartbeatExecutionOptions,
HEARTBEAT_SYSTEM_PROMPT,
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
} from "./agent-heartbeat.js";
import { AgentLogger } from "./agent-logger.js";
import * as agentTools from "./agent-tools.js";
} from "../agent-heartbeat.js";
import { AgentLogger } from "../agent-logger.js";
import * as agentTools from "../agent-tools.js";
import type { AgentStore, AgentHeartbeatRun, TaskStore, TaskDetail, Agent, MessageStore, Message, AgentBudgetStatus } from "@fusion/core";
// Mock logger to suppress noise in test output
vi.mock("./logger.js", () => {
vi.mock("../logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
@@ -38,7 +38,7 @@ vi.mock("./logger.js", () => {
});
// Mock pi.ts for executeHeartbeat tests
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
promptWithFallback: vi.fn(async (session: any, prompt: string) => {
await session.prompt(prompt);
@@ -46,8 +46,8 @@ vi.mock("./pi.js", () => ({
}));
// Import the mocked functions for test control
import { createFnAgent } from "./pi.js";
import { heartbeatLog } from "./logger.js";
import { createFnAgent } from "../pi.js";
import { heartbeatLog } from "../logger.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
// Mock store factory
@@ -1908,7 +1908,7 @@ describe("HeartbeatMonitor", () => {
describe("slot-saturation: heartbeat runs on utility lane independent of task-lane semaphore", () => {
it("executes heartbeat successfully while task-lane semaphore is saturated", async () => {
// Import AgentSemaphore directly to create a saturated slot fixture
const { AgentSemaphore } = await import("./concurrency.js");
const { AgentSemaphore } = await import("../concurrency.js");
// Create a semaphore with maxConcurrent=0 to simulate fully saturated state
// The defensive guard in AgentSemaphore.limit returns minimum 1, so we
@@ -1953,7 +1953,7 @@ describe("HeartbeatMonitor", () => {
});
it("completes on_demand heartbeat while task-lane slots are fully occupied", async () => {
const { AgentSemaphore } = await import("./concurrency.js");
const { AgentSemaphore } = await import("../concurrency.js");
// Simulate multiple task-lane agents holding all slots
const taskLaneSemaphore = new AgentSemaphore(2);
@@ -4071,7 +4071,7 @@ describe("HeartbeatMonitor", () => {
describe("HeartbeatTriggerScheduler", () => {
let store: AgentStore;
let callback: ReturnType<typeof vi.fn>;
let scheduler: import("./agent-heartbeat.js").HeartbeatTriggerScheduler;
let scheduler: import("../agent-heartbeat.js").HeartbeatTriggerScheduler;
beforeEach(() => {
callback = vi.fn().mockResolvedValue(undefined);

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AgentLogger, summarizeToolArgs } from "./agent-logger.js";
import { AgentLogger, summarizeToolArgs } from "../agent-logger.js";
import type { TaskStore } from "@fusion/core";
const loggerWarnSpy = vi.hoisted(() => vi.fn());
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
createLogger: () => ({
log: vi.fn(),
warn: loggerWarnSpy,

View File

@@ -11,14 +11,14 @@ import type {
Task,
} from "@fusion/core";
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
promptWithFallback: vi.fn(),
}));
import { createFnAgent, promptWithFallback } from "./pi.js";
import { AgentReflectionService } from "./agent-reflection.js";
import { createReflectOnPerformanceTool, reflectOnPerformanceParams } from "./agent-tools.js";
import { createFnAgent, promptWithFallback } from "../pi.js";
import { AgentReflectionService } from "../agent-reflection.js";
import { createReflectOnPerformanceTool, reflectOnPerformanceParams } from "../agent-tools.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedPromptWithFallback = vi.mocked(promptWithFallback);

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Agent, AgentStore, TaskStore, Task } from "@fusion/core";
import { createListAgentsTool, createDelegateTaskTool } from "./agent-tools.js";
import { createListAgentsTool, createDelegateTaskTool } from "../agent-tools.js";
function createMockAgentStore(overrides: Partial<AgentStore> = {}): AgentStore {
return {

View File

@@ -12,7 +12,7 @@ import {
qmdAgentMemoryCollectionName,
sendMessageParams,
readMessagesParams,
} from "./agent-tools.js";
} from "../agent-tools.js";
import type { MessageStore, Message } from "@fusion/core";
const loggerSpies = vi.hoisted(() => ({
@@ -34,7 +34,7 @@ vi.mock("node:fs/promises", async () => {
});
// Mock logger
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
createLogger: vi.fn(() => ({
log: loggerSpies.log,
warn: loggerSpies.warn,

View File

@@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFusionAuthStorage, getFusionAuthPath } from "./auth-storage.js";
import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
describe("createFusionAuthStorage", () => {
// HOME override required — createFusionAuthStorage() has no dir parameter

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
import { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "../concurrency.js";
describe("AgentSemaphore", () => {
it("allows immediate acquire when under limit", async () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { isContextLimitError } from "./context-limit-detector.js";
import { isContextLimitError } from "../context-limit-detector.js";
describe("isContextLimitError", () => {
// ── Positive matches: known provider patterns ──────────────────────

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CronRunner, createAiPromptExecutor } from "./cron-runner.js";
import type { AiPromptExecutor } from "./cron-runner.js";
import { CronRunner, createAiPromptExecutor } from "../cron-runner.js";
import type { AiPromptExecutor } from "../cron-runner.js";
import type { TaskStore, AutomationStore, ScheduledTask, AutomationRunResult, AutomationStep, Settings } from "@fusion/core";
import { randomUUID } from "node:crypto";
@@ -15,7 +15,7 @@ const piModuleMocks = vi.hoisted(() => ({
promptWithFallback: vi.fn(),
}));
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
createLogger: () => ({
log: cronLoggerSpies.log,
warn: cronLoggerSpies.warn,
@@ -23,7 +23,7 @@ vi.mock("./logger.js", () => ({
}),
}));
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: piModuleMocks.createFnAgent,
promptWithFallback: piModuleMocks.promptWithFallback,
}));

View File

@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { AgentSemaphore } from "./concurrency.js";
import { detectReviewHandoffIntent, determineRevisionResetStart } from "./executor.js";
import { AgentSemaphore } from "../concurrency.js";
import { detectReviewHandoffIntent, determineRevisionResetStart } from "../executor.js";
// Mock external dependencies
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
compactSessionContext: vi.fn(async (session, instructions) => {
@@ -21,10 +21,10 @@ vi.mock("./pi.js", () => ({
}
}),
}));
vi.mock("./reviewer.js", () => ({
vi.mock("../reviewer.js", () => ({
reviewStep: vi.fn(),
}));
vi.mock("./logger.js", () => {
vi.mock("../logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
@@ -54,12 +54,12 @@ vi.mock("./logger.js", () => {
},
};
});
vi.mock("./merger.js", () => ({
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");
vi.mock("../agent-session-helpers.js", async () => {
const { createFnAgent } = await import("../pi.js");
return {
createResolvedAgentSession: async (options: any) => {
const result = await createFnAgent(options);
@@ -72,8 +72,8 @@ vi.mock("./agent-session-helpers.js", async () => {
},
};
});
vi.mock("./worktree-names.js", async () => {
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
vi.mock("../worktree-names.js", async () => {
const actual = await vi.importActual<typeof import("../worktree-names.js")>("../worktree-names.js");
return {
...actual,
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
@@ -157,7 +157,7 @@ const mockExecuteAll = vi.fn().mockResolvedValue([]);
const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
const mockCleanup = vi.fn().mockResolvedValue(undefined);
vi.mock("./step-session-executor.js", () => ({
vi.mock("../step-session-executor.js", () => ({
StepSessionExecutor: vi.fn().mockImplementation(() => ({
executeAll: mockExecuteAll,
terminateAllSessions: mockTerminateAllSessions,
@@ -165,7 +165,7 @@ vi.mock("./step-session-executor.js", () => ({
})),
}));
vi.mock("./rate-limit-retry.js", () => ({
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
}));
vi.mock("@mariozechner/pi-coding-agent", () => {
@@ -187,21 +187,21 @@ vi.mock("@mariozechner/pi-coding-agent", () => {
};
});
import { TaskExecutor, buildExecutionPrompt } from "./executor.js";
import { createFnAgent } from "./pi.js";
import { reviewStep as mockedReviewStepFn } from "./reviewer.js";
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
import { createFnAgent } from "../pi.js";
import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
import { execSync } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findWorktreeUser, aiMergeTask } from "./merger.js";
import { WorktreePool } from "./worktree-pool.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { findWorktreeUser, aiMergeTask } from "../merger.js";
import { WorktreePool } from "../worktree-pool.js";
import { generateWorktreeName, slugify } from "../worktree-names.js";
import type { Task, TaskDetail } from "@fusion/core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { StepSessionExecutor } from "./step-session-executor.js";
import { executorLog } from "./logger.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
import { StepSessionExecutor } from "../step-session-executor.js";
import { executorLog } from "../logger.js";
import { withRateLimitRetry } from "../rate-limit-retry.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedSessionManager = vi.mocked(SessionManager);
@@ -2694,7 +2694,7 @@ describe("summarizeToolArgs", () => {
let summarizeToolArgs: (name: string, args?: Record<string, unknown>) => string | undefined;
beforeEach(async () => {
const mod = await vi.importActual<typeof import("./executor.js")>("./executor.js");
const mod = await vi.importActual<typeof import("../executor.js")>("../executor.js");
summarizeToolArgs = mod.summarizeToolArgs;
});
@@ -5614,7 +5614,7 @@ describe("fn_task_add_dep tool", () => {
// ── Usage limit detection in executor ────────────────────────────────
import { UsageLimitPauser } from "./usage-limit-detector.js";
import { UsageLimitPauser } from "../usage-limit-detector.js";
describe("TaskExecutor usage limit detection", () => {
beforeEach(() => {
@@ -9453,7 +9453,7 @@ describe("TaskExecutor context limit error recovery", () => {
const mockSession = createMockSessionForContextRecovery();
// Mock compactSessionContext to succeed
const { compactSessionContext } = await import("./pi.js");
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValueOnce({
summary: "Compacted conversation",
tokensBefore: 150000,
@@ -9483,12 +9483,12 @@ describe("TaskExecutor context limit error recovery", () => {
// The executor should catch this error and attempt recovery
// We can't directly test the catch block, but we can test that isContextLimitError
// now correctly identifies this error
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
expect(isContextLimitError(contextError.message)).toBe(true);
});
it("recognizes 'context window exceeds limit' as context limit error", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
// These are the specific error formats that should be recognized
expect(isContextLimitError("invalid params, context window exceeds limit (2013)")).toBe(true);
@@ -9498,7 +9498,7 @@ describe("TaskExecutor context limit error recovery", () => {
});
it("does NOT recognize generic 'limit exceeded' without context keywords", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
// These should NOT be recognized as context limit errors
expect(isContextLimitError("limit exceeded")).toBe(false);
@@ -9509,7 +9509,7 @@ describe("TaskExecutor context limit error recovery", () => {
it("reduced-prompt retry is attempted when compact returns null", async () => {
// This test verifies the recovery flow when compactSessionContext returns null
// (no history to compact) - the code should fall through to reduced-prompt retry
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
// These error formats should trigger reduced-prompt recovery
const contextError = "context window exceeds limit (2013)";
@@ -10103,7 +10103,7 @@ describe("Agent Spawning - runSpawnedChild", () => {
internals.totalSpawnedCount = 1;
// Make promptWithFallback throw
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
vi.mocked(promptWithFallback).mockRejectedValueOnce(new Error("API error"));
await internals.runSpawnedChild("agent-test", mockSession, "Do the research");

View File

@@ -8,7 +8,7 @@ import {
worktreePoolLog,
reviewerLog,
remoteNodeLog,
} from "./logger.js";
} from "../logger.js";
describe("createLogger", () => {
let logSpy: ReturnType<typeof vi.spyOn>;

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock external dependencies
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
describeModel: vi.fn(() => "mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session, prompt, options) => {
@@ -82,11 +82,11 @@ vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
}));
vi.mock("./rate-limit-retry.js", () => ({
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: (fn: () => Promise<any>) => fn(),
}));
vi.mock("./context-limit-detector.js", () => ({
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn(),
}));
@@ -112,9 +112,9 @@ import {
summarizeVerificationOutput,
inferDefaultTestCommand,
type ConflictCategory,
} from "./merger.js";
import { mergerLog } from "./logger.js";
import { createFnAgent } from "./pi.js";
} from "../merger.js";
import { mergerLog } from "../logger.js";
import { createFnAgent } from "../pi.js";
import { execSync, exec } from "node:child_process";
import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core";
@@ -1160,7 +1160,7 @@ describe("aiMergeTask — agent log persistence", () => {
// ── Usage limit detection in merger ──────────────────────────────────
import { UsageLimitPauser } from "./usage-limit-detector.js";
import { UsageLimitPauser } from "../usage-limit-detector.js";
describe("aiMergeTask — usage limit detection", () => {
beforeEach(() => {
@@ -4529,7 +4529,7 @@ describe("aiMergeTask — fresh session and compaction recovery", () => {
it("imports compactSessionContext and isContextLimitError from respective modules", async () => {
// This test verifies the imports are present in merger.ts
// The actual functionality is tested via behavior verification
const mergerModule = await import("./merger.js");
const mergerModule = await import("../merger.js");
expect(mergerModule).toBeDefined();
});
});
@@ -4538,7 +4538,7 @@ describe("aiMergeTask — fresh session and compaction recovery", () => {
describe("buildMergePrompt — truncation behavior", () => {
it("truncates commit log when exceeding MERGE_COMMIT_LOG_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
// Create a commit log that exceeds 5000 characters
const longCommitLog = "- " + "a".repeat(6000);
@@ -4557,7 +4557,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("truncates diff stat when exceeding MERGE_DIFF_STAT_MAX_CHARS", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
// Create a diff stat that exceeds 3000 characters
const longDiffStat = "file.ts | " + " ".repeat(10) + "x".repeat(4000);
@@ -4576,7 +4576,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("preserves short content unchanged (under limits)", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
const shortCommitLog = "- feat: add login\n- fix: correct typo";
const shortDiffStat = "src/login.ts | 5 +++\n1 file changed";
@@ -4596,7 +4596,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("truncates commit log but not diff stat when only commit log is over limit", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
const longCommitLog = "- " + "b".repeat(6000);
const shortDiffStat = "1 file changed";
@@ -4615,7 +4615,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("includes author arg in no-conflicts commit instruction", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
@@ -4630,7 +4630,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("includes author arg in conflicts commit instruction", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
@@ -4645,7 +4645,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("omits author instruction when authorArg is not provided", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
@@ -4660,7 +4660,7 @@ describe("buildMergePrompt — truncation behavior", () => {
});
it("handles empty authorArg gracefully", async () => {
const { buildMergePrompt } = await import("./merger.js");
const { buildMergePrompt } = await import("../merger.js");
const prompt = buildMergePrompt({
taskId: "FN-001",
@@ -4700,7 +4700,7 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
}
it("retries with minimal prompt when context limit hit after auto-compaction", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
@@ -4748,7 +4748,7 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
});
it("throws when truncated retry also fails with context limit", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
@@ -4810,7 +4810,7 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
});
it("succeeds when prompt succeeds on retry after context error", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
vi.mocked(isContextLimitError).mockReturnValue(true);
@@ -4851,8 +4851,8 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
});
it("does not attempt truncation retry for non-context errors", async () => {
const { compactSessionContext } = await import("./pi.js");
const { isContextLimitError } = await import("./context-limit-detector.js");
const { compactSessionContext } = await import("../pi.js");
const { isContextLimitError } = await import("../context-limit-detector.js");
// Non-context error should not trigger recovery path
vi.mocked(compactSessionContext).mockResolvedValue(null);
@@ -5328,7 +5328,7 @@ describe("aiMergeTask — inferred test command execution", () => {
describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("./session-skill-context.js", () => ({
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
@@ -5337,7 +5337,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
});
it("passes skillSelection to createFnAgent when agentStore is provided", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
@@ -5384,7 +5384,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
});
it("uses assigned agent skills when available", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
@@ -5428,7 +5428,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
});
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
@@ -5495,7 +5495,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
});
it("gracefully handles buildSessionSkillContext throwing", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
mockedCreateFnAgent.mockResolvedValue({
@@ -5532,7 +5532,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
});
it("records resolved skill names in skill context result", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
const resolvedNames = ["skill-a", "skill-b"];
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
@@ -5576,7 +5576,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
});
it("uses sessionPurpose='merger' in skill selection context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
@@ -5621,7 +5621,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("./session-skill-context.js", () => ({
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
@@ -5635,7 +5635,7 @@ describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511
});
it("merge continues when skill selection produces diagnostics", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
// Simulate diagnostics being logged - the resolver would produce these
// when requested skills are not found or filtered
vi.mocked(buildSessionSkillContext).mockResolvedValue({
@@ -5679,7 +5679,7 @@ describe("aiMergeTask — skill selection non-fatal diagnostics (FN-1510/FN-1511
});
it("records skill source in context result for debugging", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",

View File

@@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MissionAutopilot } from "./mission-autopilot.js";
import { MissionAutopilot } from "../mission-autopilot.js";
import type { Mission, Milestone, Slice, MissionFeature } from "@fusion/core";
// ── Mock Factories ──────────────────────────────────────────────────

View File

@@ -30,13 +30,13 @@ const mockSessionHolder: {
};
// Mock the pi module before MissionExecutionLoop is imported
vi.mock("./pi.js", () => {
vi.mock("../pi.js", () => {
const createFnAgent = vi.fn(() => Promise.resolve({ session: mockSessionHolder.session }));
const promptWithFallback = vi.fn().mockResolvedValue(undefined);
return { createFnAgent, promptWithFallback };
});
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
createLogger: vi.fn((_name: string) => ({
log: vi.fn(),
warn: vi.fn(),
@@ -51,7 +51,7 @@ function resetMockSession() {
}
// Import AFTER vi.mock so the mock is applied
import { MissionExecutionLoop, loopLog } from "./mission-execution-loop.js";
import { MissionExecutionLoop, loopLog } from "../mission-execution-loop.js";
// ── Mock Factories ──────────────────────────────────────────────────────────

View File

@@ -13,8 +13,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Mission, MissionStore, TaskStore, Task } from "@fusion/core";
import { Scheduler } from "./scheduler.js";
import { MissionAutopilot } from "./mission-autopilot.js";
import { Scheduler } from "../scheduler.js";
import { MissionAutopilot } from "../mission-autopilot.js";
// ── Mock Factories ─────────────────────────────────────────────────

View File

@@ -9,8 +9,8 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Scheduler } from "./scheduler.js";
import { AgentSemaphore } from "./concurrency.js";
import { Scheduler } from "../scheduler.js";
import { AgentSemaphore } from "../concurrency.js";
import type { TaskStore, MissionStore, Slice, Mission, Milestone, MissionFeature } from "@fusion/core";
// Mock store factory

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CentralCore, NodeConfig } from "@fusion/core";
import { NodeHealthMonitor } from "./node-health-monitor.js";
import { NodeHealthMonitor } from "../node-health-monitor.js";
const NOW = "2026-04-08T00:00:00.000Z";

View File

@@ -7,10 +7,10 @@ import {
buildNtfyClickUrl,
isNtfyEventEnabled,
resolveNtfyEvents,
} from "./notifier.js";
} from "../notifier.js";
// Mock the logger
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
schedulerLog: { log: vi.fn(), error: vi.fn() },
}));

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CentralCore, NodeConfig, PeerInfo, SettingsSyncPayload } from "@fusion/core";
import { PeerExchangeService } from "./peer-exchange-service.js";
import { PeerExchangeService } from "../peer-exchange-service.js";
function makeNode(overrides: Partial<NodeConfig> = {}): NodeConfig {
return {

View File

@@ -91,6 +91,9 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
getAgentDir: () => "/mock-agent-dir",
ModelRegistry: class {
static create(...args: unknown[]) {
return new (this as unknown as new () => unknown)();
}
find(provider: string, modelId: string) {
return findMock(provider, modelId);
}
@@ -132,7 +135,7 @@ describe("worktree path boundary helpers", () => {
const tools = [mockReadTool as any];
// Simulate wrapping (normally done inside createFnAgent)
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
tools,
"/project/.worktrees/fn-001", // worktree path
@@ -149,7 +152,7 @@ describe("worktree path boundary helpers", () => {
// Read outside worktree should be rejected
const outsideResult = await (wrapped[0] as any).execute("call-2", { path: "/other/project/file.ts" });
expect(outsideResult).toEqual({
expect(outsideResult).toMatchObject({
ok: false,
error: expect.stringContaining("outside the worktree boundary"),
});
@@ -165,7 +168,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [{ type: "text", text: "memory content" }] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockReadTool as any],
@@ -206,7 +209,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [{ type: "text", text: "daily memory" }] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockReadTool as any],
@@ -228,7 +231,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [{ type: "text", text: "attachment content" }] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockReadTool as any],
@@ -251,7 +254,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary([mockTool as any], null, null);
@@ -272,7 +275,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockTaskTool as any],
@@ -293,7 +296,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockWriteTool as any],
@@ -303,7 +306,7 @@ describe("worktree path boundary helpers", () => {
// Writing outside worktree should be rejected
const result = await (wrapped[0] as any).execute("call-1", { path: "/another/project/file.ts" });
expect(result).toEqual({
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("outside the worktree boundary"),
});
@@ -319,7 +322,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockBashTool as any],
@@ -329,7 +332,7 @@ describe("worktree path boundary helpers", () => {
// Bash with cwd outside worktree should be rejected
const result = await (wrapped[0] as any).execute("call-1", { command: "ls -la", cwd: "/another/project" });
expect(result).toEqual({
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("outside the worktree boundary"),
});
@@ -345,7 +348,7 @@ describe("worktree path boundary helpers", () => {
execute: vi.fn().mockResolvedValue({ ok: true, content: [{ type: "text", text: "ls result" }] }),
};
const { wrapToolsWithBoundary } = await import("./pi.js");
const { wrapToolsWithBoundary } = await import("../pi.js");
const wrapped = wrapToolsWithBoundary(
[mockBashTool as any],
@@ -396,7 +399,7 @@ describe("createFnAgent", () => {
return "worktree /project\nHEAD abc123\nbranch refs/heads/main\n";
});
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/project/.worktrees/fn-001",
@@ -423,7 +426,7 @@ describe("createFnAgent", () => {
"worktree /project/.worktrees/fn-001\nHEAD def456\nbranch refs/heads/fusion/fn-001\n";
});
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/project/.worktrees/fn-001",
@@ -453,7 +456,7 @@ describe("createFnAgent", () => {
errors: [],
});
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
@@ -475,7 +478,7 @@ describe("createFnAgent", () => {
});
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
@@ -496,7 +499,7 @@ describe("createFnAgent", () => {
provider === "zai" && modelId === "glm-5.1" ? undefined : { provider, id: modelId }
));
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/tmp",
@@ -514,7 +517,7 @@ describe("createFnAgent", () => {
provider === "openai-codex" && modelId === "missing-model" ? undefined : { provider, id: modelId }
));
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/tmp",
@@ -530,7 +533,7 @@ describe("createFnAgent", () => {
});
it("creates a session when configured models resolve successfully", async () => {
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
@@ -571,7 +574,7 @@ describe("createFnAgent", () => {
},
});
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
const { session } = await createFnAgent({
cwd: "/tmp",
@@ -621,7 +624,7 @@ describe("createFnAgent", () => {
},
});
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
const { session } = await createFnAgent({
cwd: "/tmp",
@@ -644,7 +647,7 @@ describe("createFnAgent", () => {
});
it("enables auto-compaction to prevent context-window overflow", async () => {
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
@@ -661,7 +664,7 @@ describe("createFnAgent", () => {
});
it("passes compaction enabled alongside retry settings", async () => {
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
@@ -692,7 +695,7 @@ describe("createFnAgent", () => {
};
createAgentSessionMock.mockResolvedValueOnce({ session });
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
const { session: guardedSession } = await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
@@ -723,7 +726,7 @@ describe("createFnAgent", () => {
_rewriteFile: rewriteFile,
};
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
@@ -769,7 +772,7 @@ describe("createFnAgent", () => {
};
createAgentSessionMock.mockResolvedValueOnce({ session });
const { createFnAgent } = await import("./pi.js");
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
@@ -831,6 +834,9 @@ describe("createFnAgent", () => {
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
getAgentDir: () => "/mock-agent-dir",
ModelRegistry: class {
static create(...args: unknown[]) {
return new (this as unknown as new () => unknown)();
}
find(provider: string, modelId: string) {
return findMock(provider, modelId);
}
@@ -853,7 +859,7 @@ describe("createFnAgent", () => {
},
}));
const { createFnAgent: freshCreateFnAgent } = await import("./pi.js");
const { createFnAgent: freshCreateFnAgent } = await import("../pi.js");
await freshCreateFnAgent({
cwd: "/tmp",
@@ -906,6 +912,9 @@ describe("createFnAgent", () => {
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
getAgentDir: () => "/mock-agent-dir",
ModelRegistry: class {
static create(...args: unknown[]) {
return new (this as unknown as new () => unknown)();
}
find(provider: string, modelId: string) {
return findMock(provider, modelId);
}
@@ -928,7 +937,7 @@ describe("createFnAgent", () => {
},
}));
const { createFnAgent: freshCreateFnAgent } = await import("./pi.js");
const { createFnAgent: freshCreateFnAgent } = await import("../pi.js");
await freshCreateFnAgent({
cwd: "/tmp",
@@ -978,6 +987,9 @@ describe("createFnAgent", () => {
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
getAgentDir: () => "/mock-agent-dir",
ModelRegistry: class {
static create(...args: unknown[]) {
return new (this as unknown as new () => unknown)();
}
find(provider: string, modelId: string) {
return findMock(provider, modelId);
}
@@ -1000,7 +1012,7 @@ describe("createFnAgent", () => {
},
}));
const { createFnAgent: freshCreateFnAgent } = await import("./pi.js");
const { createFnAgent: freshCreateFnAgent } = await import("../pi.js");
await freshCreateFnAgent({
cwd: "/tmp",
@@ -1031,11 +1043,11 @@ describe("createFnAgent", () => {
});
it("diagnostics are logged via structured logger with [skills] context", async () => {
const { piLog } = await import("./logger.js");
const { piLog } = await import("../logger.js");
const piWarnSpy = vi.spyOn(piLog, "warn").mockImplementation(() => {});
// Test diagnostics logging by directly calling createSkillsOverrideFromSelection
const { createSkillsOverrideFromSelection } = await import("./skill-resolver.js");
const { createSkillsOverrideFromSelection } = await import("../skill-resolver.js");
const selection = {
allowedSkillPaths: new Set(["/path/nonexistent"]),

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, promptWithFallback, type AgentOptions } from "./pi.js";
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, promptWithFallback, type AgentOptions } from "../pi.js";
import { createAgentSession, type AgentSession } from "@mariozechner/pi-coding-agent";
import { piLog } from "./logger.js";
import { piLog } from "../logger.js";
// Mock skill resolver functions - define inside factory to avoid hoisting issues
vi.mock("./skill-resolver.js", () => {
vi.mock("../skill-resolver.js", () => {
const resolveSessionSkillsMock = vi.fn();
const createSkillsOverrideFromSelectionMock = vi.fn();
return {
@@ -43,12 +43,22 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
DefaultPackageManager: vi.fn(),
discoverAndLoadExtensions: vi.fn().mockResolvedValue({ errors: [], runtime: { pendingProviderRegistrations: [] } }),
getAgentDir: vi.fn(() => "/test/agent-dir"),
ModelRegistry: vi.fn().mockImplementation(() => ({
find: vi.fn().mockReturnValue({ provider: "test", id: "test-model" }),
getAll: vi.fn().mockReturnValue([]),
registerProvider: vi.fn(),
refresh: vi.fn(),
})),
ModelRegistry: Object.assign(
vi.fn().mockImplementation(() => ({
find: vi.fn().mockReturnValue({ provider: "test", id: "test-model" }),
getAll: vi.fn().mockReturnValue([]),
registerProvider: vi.fn(),
refresh: vi.fn(),
})),
{
create: vi.fn().mockReturnValue({
find: vi.fn().mockReturnValue({ provider: "test", id: "test-model" }),
getAll: vi.fn().mockReturnValue([]),
registerProvider: vi.fn(),
refresh: vi.fn(),
}),
},
),
SessionManager: {
inMemory: vi.fn(() => ({})),
},
@@ -276,7 +286,7 @@ describe("createFnAgent skills parameter", () => {
piErrorSpy = vi.spyOn(piLog, "error").mockImplementation(() => {});
// Access the mocked module to get/set mocks
const skillResolver = await import("./skill-resolver.js");
const skillResolver = await import("../skill-resolver.js");
mockResolveSessionSkills = vi.mocked(skillResolver.resolveSessionSkills);
mockCreateSkillsOverride = vi.mocked(skillResolver.createSkillsOverrideFromSelection);

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PrCommentHandler } from "./pr-comment-handler.js";
import { PrCommentHandler } from "../pr-comment-handler.js";
import type { TaskStore, Task } from "@fusion/core";
const mockStore = {

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { PrMonitor, type PrComment } from "./pr-monitor.js";
import type { PrMonitorGhClient } from "./pr-monitor-gh.js";
import { PrMonitor, type PrComment } from "../pr-monitor.js";
import type { PrMonitorGhClient } from "../pr-monitor-gh.js";
describe("PrMonitor", () => {
let monitor: PrMonitor;

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectEngine } from "./project-engine.js";
import { runtimeLog } from "./logger.js";
import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -28,7 +28,7 @@ vi.mock("@fusion/core", async () => {
};
});
vi.mock("./cron-runner.js", () => {
vi.mock("../cron-runner.js", () => {
return {
CronRunner: vi.fn().mockImplementation(() => ({
start: mocks.cronRunnerStart,
@@ -38,26 +38,26 @@ vi.mock("./cron-runner.js", () => {
};
});
vi.mock("./pr-monitor.js", () => ({
vi.mock("../pr-monitor.js", () => ({
PrMonitor: vi.fn().mockImplementation(() => ({
onNewComments: vi.fn(),
})),
}));
vi.mock("./pr-comment-handler.js", () => ({
vi.mock("../pr-comment-handler.js", () => ({
PrCommentHandler: vi.fn().mockImplementation(() => ({
handleNewComments: vi.fn(),
})),
}));
vi.mock("./notifier.js", () => ({
vi.mock("../notifier.js", () => ({
NtfyNotifier: vi.fn().mockImplementation(() => ({
start: vi.fn(async () => undefined),
stop: vi.fn(),
})),
}));
vi.mock("./runtimes/in-process-runtime.js", () => ({
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: mocks.runtimeStart,
stop: mocks.runtimeStop,

View File

@@ -1,13 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CentralCore, RegisteredProject, Task } from "@fusion/core";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
import { ChildProcessRuntime } from "./runtimes/child-process-runtime.js";
import { RemoteNodeRuntime } from "./runtimes/remote-node-runtime.js";
import { ProjectManager } from "./project-manager.js";
import type { ProjectRuntimeConfig } from "./project-runtime.js";
import { InProcessRuntime } from "../runtimes/in-process-runtime.js";
import { ChildProcessRuntime } from "../runtimes/child-process-runtime.js";
import { RemoteNodeRuntime } from "../runtimes/remote-node-runtime.js";
import { ProjectManager } from "../project-manager.js";
import type { ProjectRuntimeConfig } from "../project-runtime.js";
// Mock the runtimes
vi.mock("./runtimes/in-process-runtime.js", () => ({
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -23,7 +23,7 @@ vi.mock("./runtimes/in-process-runtime.js", () => ({
})),
}));
vi.mock("./runtimes/child-process-runtime.js", () => ({
vi.mock("../runtimes/child-process-runtime.js", () => ({
ChildProcessRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
@@ -43,7 +43,7 @@ vi.mock("./runtimes/child-process-runtime.js", () => ({
})),
}));
vi.mock("./runtimes/remote-node-runtime.js", () => ({
vi.mock("../runtimes/remote-node-runtime.js", () => ({
RemoteNodeRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { withRateLimitRetry } from "./rate-limit-retry.js";
import { withRateLimitRetry } from "../rate-limit-retry.js";
describe("withRateLimitRetry", () => {
beforeEach(() => {

View File

@@ -6,7 +6,7 @@ import {
BASE_DELAY_MS,
MAX_DELAY_MS,
BACKOFF_MULTIPLIER,
} from "./recovery-policy.js";
} from "../recovery-policy.js";
describe("computeRecoveryDecision", () => {
afterEach(() => {

View File

@@ -10,13 +10,13 @@
* - Crash scenarios are handled gracefully (semaphore release, status cleanup)
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AgentSemaphore } from "./concurrency.js";
import { AgentSemaphore } from "../concurrency.js";
/* eslint-disable @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-explicit-any -- Test mocks use Function/any type for simplicity */
// ── Module-level mocks (matching existing test patterns) ──────────────────
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session, prompt, options) => {
@@ -27,11 +27,11 @@ vi.mock("./pi.js", () => ({
}
}),
}));
vi.mock("./reviewer.js", () => ({
vi.mock("../reviewer.js", () => ({
reviewStep: vi.fn(),
}));
vi.mock("./agent-session-helpers.js", async () => {
const { createFnAgent } = await import("./pi.js");
vi.mock("../agent-session-helpers.js", async () => {
const { createFnAgent } = await import("../pi.js");
return {
createResolvedAgentSession: async (options: any) => {
const result = await createFnAgent(options);
@@ -105,12 +105,12 @@ vi.mock("@mariozechner/pi-coding-agent", () => {
};
});
import { TaskExecutor } from "./executor.js";
import { TriageProcessor } from "./triage.js";
import { Scheduler } from "./scheduler.js";
import { aiMergeTask } from "./merger.js";
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
import { createFnAgent } from "./pi.js";
import { TaskExecutor } from "../executor.js";
import { TriageProcessor } from "../triage.js";
import { Scheduler } from "../scheduler.js";
import { aiMergeTask } from "../merger.js";
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "../worktree-pool.js";
import { createFnAgent } from "../pi.js";
import { execSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import type { Task, TaskDetail, TaskStep, Column, Settings, StepStatus } from "@fusion/core";
@@ -1571,7 +1571,7 @@ describe("getTaskMergeBlocker import regression", () => {
// Verify the merger module itself loads without import errors.
// aiMergeTask internally uses getTaskMergeBlocker from @fusion/core —
// if the core export is broken, this dynamic import will fail.
const merger = await import("./merger.js");
const merger = await import("../merger.js");
expect(typeof merger.aiMergeTask).toBe("function");
expect(typeof merger.findWorktreeUser).toBe("function");
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
promptWithFallback: vi.fn(async (session, prompt, options) => {
@@ -12,8 +12,8 @@ vi.mock("./pi.js", () => ({
}),
}));
import { reviewStep, REVIEWER_SYSTEM_PROMPT } from "./reviewer.js";
import { createFnAgent } from "./pi.js";
import { reviewStep, REVIEWER_SYSTEM_PROMPT } from "../reviewer.js";
import { createFnAgent } from "../pi.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
@@ -639,7 +639,7 @@ describe("reviewStep — user comments in spec review", () => {
describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", () => {
// Mock session-skill-context to control skill selection behavior
vi.mock("./session-skill-context.js", () => ({
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn(),
}));
@@ -648,7 +648,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
});
it("passes skillSelection to createFnAgent when agentStore and rootDir are provided", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
@@ -685,7 +685,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
});
it("uses assigned agent skills when available", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
@@ -722,7 +722,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
});
it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
@@ -772,7 +772,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
});
it("gracefully handles buildSessionSkillContext throwing", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockRejectedValue(new Error("Agent not found"));
mockedCreateFnAgent.mockResolvedValue(
@@ -799,7 +799,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
});
it("records resolved skill names in skill context result", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
const resolvedNames = ["skill-a", "skill-b", "skill-c"];
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
@@ -835,7 +835,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
});
it("uses sessionPurpose='reviewer' in skill selection context", async () => {
const { buildSessionSkillContext } = await import("./session-skill-context.js");
const { buildSessionSkillContext } = await import("../session-skill-context.js");
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { RoutineRunner, type RoutineRunnerOptions } from "./routine-runner.js";
import { RoutineRunner, type RoutineRunnerOptions } from "../routine-runner.js";
import type {
RoutineStore,
Routine,
@@ -8,7 +8,7 @@ import type {
TaskStore,
Settings,
} from "@fusion/core";
import type { HeartbeatMonitor } from "./agent-heartbeat.js";
import type { HeartbeatMonitor } from "../agent-heartbeat.js";
// Default settings inline to avoid @fusion/core build dependency during tests
const DEFAULT_SETTINGS: Settings = {

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { RoutineScheduler, type RoutineSchedulerOptions } from "./routine-scheduler.js";
import { RoutineScheduler, type RoutineSchedulerOptions } from "../routine-scheduler.js";
import type { RoutineStore, Routine, TaskStore, RoutineExecutionResult, Settings } from "@fusion/core";
import type { RoutineRunner } from "./routine-runner.js";
import type { RoutineRunner } from "../routine-runner.js";
// Default settings inline to avoid @fusion/core build dependency during tests
const DEFAULT_SETTINGS: Settings = {

View File

@@ -16,7 +16,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { TaskStore, RunAuditEvent, RunAuditEventFilter, RunAuditEventInput } from "@fusion/core";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "./run-audit.js";
import { createRunAuditor, generateSyntheticRunId, type EngineRunContext } from "../run-audit.js";
// NOTE: This file uses mock stores/fakes instead of real SQLite databases.
// See FN-2142 for the rationale.

View File

@@ -8,18 +8,18 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "./agent-runtime.js";
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";
} 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", () => ({
vi.mock("../logger.js", () => ({
createLogger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
@@ -28,7 +28,7 @@ vi.mock("./logger.js", () => ({
}));
// Mock pi.js to avoid actual session creation
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn().mockResolvedValue({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },

View File

@@ -13,7 +13,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the logger to suppress output during tests
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
createLogger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
@@ -22,7 +22,7 @@ vi.mock("./logger.js", () => ({
}));
// Mock pi.js to avoid actual session creation
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn().mockResolvedValue({
session: {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
@@ -36,7 +36,7 @@ vi.mock("./pi.js", () => ({
// Mock the runtime resolution module
const mockResolveRuntime = vi.fn();
vi.mock("./runtime-resolution.js", () => ({
vi.mock("../runtime-resolution.js", () => ({
resolveRuntime: (...args: unknown[]) => mockResolveRuntime(...args),
buildRuntimeResolutionContext: vi.fn().mockReturnValue({
sessionPurpose: "test",
@@ -46,7 +46,7 @@ vi.mock("./runtime-resolution.js", () => ({
}));
// Mock session skill context
vi.mock("./session-skill-context.js", () => ({
vi.mock("../session-skill-context.js", () => ({
buildSessionSkillContext: vi.fn().mockResolvedValue({
skillSelectionContext: undefined,
resolvedSkillNames: [],
@@ -60,7 +60,7 @@ vi.mock("./session-skill-context.js", () => ({
}));
// Mock agent instructions
vi.mock("./agent-instructions.js", () => ({
vi.mock("../agent-instructions.js", () => ({
resolveAgentInstructions: vi.fn().mockResolvedValue(""),
buildSystemPromptWithInstructions: vi.fn().mockImplementation((base) => base),
resolveAgentInstructionsWithRatings: vi.fn().mockResolvedValue(""),
@@ -95,7 +95,7 @@ describe("Runtime Selection Regression Tests", () => {
describe("createResolvedAgentSession", () => {
it("should call resolveRuntime when creating a session", async () => {
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
await createResolvedAgentSession({
sessionPurpose: "executor",
@@ -126,7 +126,7 @@ describe("Runtime Selection Regression Tests", () => {
runtimeId: "test-runtime",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
await createResolvedAgentSession({
sessionPurpose: "executor",
@@ -155,7 +155,7 @@ describe("Runtime Selection Regression Tests", () => {
runtimeId: "my-runtime",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "triage",
@@ -186,7 +186,7 @@ describe("Runtime Selection Regression Tests", () => {
runtimeId: "pi",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "merger",
@@ -215,7 +215,7 @@ describe("Runtime Selection Regression Tests", () => {
runtimeId: "pi",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "heartbeat",
@@ -248,7 +248,7 @@ describe("Runtime Selection Regression Tests", () => {
runtimeId: "code-interpreter",
});
const { createResolvedAgentSession } = await import("./agent-session-helpers.js");
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const result = await createResolvedAgentSession({
sessionPurpose: "executor",

View File

@@ -1,11 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrMonitor } from "./pr-monitor.js";
import { Scheduler, pathsOverlap } from "./scheduler.js";
import { AgentSemaphore } from "./concurrency.js";
import type { PrMonitor } from "../pr-monitor.js";
import { Scheduler, pathsOverlap } from "../scheduler.js";
import { AgentSemaphore } from "../concurrency.js";
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { schedulerLog } from "./logger.js";
import { schedulerLog } from "../logger.js";
// Mock fs modules
vi.mock("node:fs", async (importOriginal) => {
@@ -24,7 +24,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
};
});
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
schedulerLog: {
log: vi.fn(),
warn: vi.fn(),

View File

@@ -49,14 +49,14 @@ vi.mock("node:fs", async (importOriginal) => {
};
});
vi.mock("./worktree-pool.js", () => ({
vi.mock("../worktree-pool.js", () => ({
WorktreePool: vi.fn(),
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
scanOrphanedBranches: vi.fn().mockResolvedValue([]),
}));
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
createLogger: vi.fn((_name: string) => ({
log: vi.fn(),
warn: vi.fn(),
@@ -64,13 +64,13 @@ vi.mock("./logger.js", () => ({
})),
}));
import { SelfHealingManager } from "./self-healing.js";
import { SelfHealingManager } from "../self-healing.js";
import type { TaskStore, Settings, Task } from "@fusion/core";
import { EventEmitter } from "node:events";
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { scanOrphanedBranches } from "./worktree-pool.js";
import { createLogger } from "./logger.js";
import { scanOrphanedBranches } from "../worktree-pool.js";
import { createLogger } from "../logger.js";
const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync);

View File

@@ -5,7 +5,7 @@ import {
buildSessionSkillContextSync,
SKILL_DIAGNOSTIC_MESSAGES,
type SessionPurpose,
} from "./session-skill-context.js";
} from "../session-skill-context.js";
import type { Agent, AgentStore } from "@fusion/core";
describe("normalizeAgentSkills", () => {

View File

@@ -12,7 +12,7 @@ const { mockPiLog } = vi.hoisted(() => ({
},
}));
vi.mock("./logger.js", () => ({
vi.mock("../logger.js", () => ({
piLog: mockPiLog,
}));
@@ -20,7 +20,7 @@ import {
resolveSessionSkills,
createSkillsOverrideFromSelection,
type SkillSelectionResult,
} from "./skill-resolver.js";
} from "../skill-resolver.js";
// ── Mock Setup ───────────────────────────────────────────────────────────────

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { evaluateSpecStaleness, getPromptPath } from "./spec-staleness.js";
import { evaluateSpecStaleness, getPromptPath } from "../spec-staleness.js";
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { Settings } from "@fusion/core";

View File

@@ -5,8 +5,8 @@ import {
determineParallelWaves,
buildStepPrompt,
StepSessionExecutor,
} from "./step-session-executor.js";
import { AgentLogger } from "./agent-logger.js";
} from "../step-session-executor.js";
import { AgentLogger } from "../agent-logger.js";
import type { TaskDetail, Settings, TaskStore } from "@fusion/core";
// ── Shared test fixtures ──────────────────────────────────────────────
@@ -537,7 +537,7 @@ Some freeform text without checkboxes.`;
// ── StepSessionExecutor test helpers ───────────────────────────────────
// Mock pi.js for StepSessionExecutor tests
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
promptWithFallback: vi.fn(async (session: any, prompt: string) => {
await session.prompt(prompt);
@@ -547,7 +547,7 @@ vi.mock("./pi.js", () => ({
}));
// Mock logger
vi.mock("./logger.js", () => {
vi.mock("../logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
@@ -580,20 +580,20 @@ vi.mock("./logger.js", () => {
});
// Mock context-limit-detector
vi.mock("./context-limit-detector.js", () => ({
vi.mock("../context-limit-detector.js", () => ({
isContextLimitError: vi.fn().mockImplementation((msg: string) =>
/context\s+window\s+exceeds/i.test(msg),
),
}));
// Mock usage-limit-detector
vi.mock("./usage-limit-detector.js", () => ({
vi.mock("../usage-limit-detector.js", () => ({
checkSessionError: vi.fn(),
}));
// Mock worktree-names
vi.mock("./worktree-names.js", async () => {
const actual = await vi.importActual<typeof import("./worktree-names.js")>("./worktree-names.js");
vi.mock("../worktree-names.js", async () => {
const actual = await vi.importActual<typeof import("../worktree-names.js")>("../worktree-names.js");
return {
...actual,
generateWorktreeName: vi.fn().mockReturnValue("test-worktree"),
@@ -639,11 +639,11 @@ vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
import { createFnAgent } from "./pi.js";
import { generateWorktreeName } from "./worktree-names.js";
import { createFnAgent } from "../pi.js";
import { generateWorktreeName } from "../worktree-names.js";
import { execSync } from "node:child_process";
import { AgentSemaphore } from "./concurrency.js";
import { createLogger } from "./logger.js";
import { AgentSemaphore } from "../concurrency.js";
import { createLogger } from "../logger.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedExecSync = vi.mocked(execSync);
@@ -1902,7 +1902,7 @@ describe("StepSessionExecutor", () => {
} as any);
// Mock promptWithFallback: first call throws, subsequent calls succeed
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
let callCount = 0;
vi.mocked(promptWithFallback).mockImplementation(async (session: any, prompt: string) => {
callCount++;
@@ -1913,7 +1913,7 @@ describe("StepSessionExecutor", () => {
});
// Mock compactSessionContext to succeed
const { compactSessionContext } = await import("./pi.js");
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValue({
summary: "Compacted",
tokensBefore: 150000,
@@ -1948,7 +1948,7 @@ describe("StepSessionExecutor", () => {
} as any);
// Mock promptWithFallback: first call throws context-limit, second succeeds (reduced prompt)
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
let callCount = 0;
vi.mocked(promptWithFallback).mockImplementation(async (session: any, prompt: string) => {
callCount++;
@@ -1959,7 +1959,7 @@ describe("StepSessionExecutor", () => {
});
// Mock compactSessionContext to return null (no history)
const { compactSessionContext } = await import("./pi.js");
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValue(null);
const executor = new StepSessionExecutor({
@@ -1990,13 +1990,13 @@ describe("StepSessionExecutor", () => {
} as any);
// Mock promptWithFallback: always throws context-limit error
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
vi.mocked(promptWithFallback).mockRejectedValue(
new Error("context window exceeds limit (2013)"),
);
// Mock compactSessionContext to return null (no history)
const { compactSessionContext } = await import("./pi.js");
const { compactSessionContext } = await import("../pi.js");
vi.mocked(compactSessionContext).mockResolvedValue(null);
const executor = new StepSessionExecutor({

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { StuckTaskDetector } from "./stuck-task-detector.js";
import { StuckTaskDetector } from "../stuck-task-detector.js";
import type { TaskStore } from "@fusion/core";
// Mock store factory

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
import { TokenCapDetector, type TokenCapCheckResult } from "../token-cap-detector.js";
/** Create a mock AgentSession with the given context usage. */
function createMockSession(

View File

@@ -4,8 +4,8 @@ import {
classifyError,
isSilentTransientError,
TRANSIENT_ERROR_PATTERNS,
} from "./transient-error-detector.js";
import { isUsageLimitError } from "./usage-limit-detector.js";
} from "../transient-error-detector.js";
import { isUsageLimitError } from "../usage-limit-detector.js";
describe("Transient Error Detector", () => {
describe("isTransientError", () => {

View File

@@ -6,23 +6,23 @@ import {
buildSpecificationPrompt,
readAttachmentContents,
computeUserCommentFingerprint,
} from "./triage.js";
} from "../triage.js";
import { join } from "node:path";
import { mkdir, writeFile, rm, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
import { triageLog } from "./logger.js";
import { triageLog } from "../logger.js";
const { mockReviewStep, mockCreateFnAgent } = vi.hoisted(() => ({
mockReviewStep: vi.fn(),
mockCreateFnAgent: vi.fn(),
}));
vi.mock("./reviewer.js", () => ({
vi.mock("../reviewer.js", () => ({
reviewStep: mockReviewStep,
}));
vi.mock("./pi.js", () => ({
vi.mock("../pi.js", () => ({
createFnAgent: mockCreateFnAgent,
describeModel: vi.fn().mockReturnValue("mock-model"),
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
@@ -1590,7 +1590,7 @@ describe("taskCreate tool model inheritance", () => {
// Make promptWithFallback invoke the fn_task_create tool twice to simulate
// the agent proactively splitting the oversized task
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(
async () => {
const taskCreateTool = capturedCustomTools.find(
@@ -1656,7 +1656,7 @@ describe("taskCreate tool model inheritance", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
const processor = new TriageProcessor(store, "/test/root", {
@@ -1890,7 +1890,7 @@ describe("taskCreate tool model inheritance", () => {
});
// Make promptWithFallback throw so we can stop execution after model log
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model log"),
);
@@ -1959,7 +1959,7 @@ describe("taskCreate tool model inheritance", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
@@ -2024,7 +2024,7 @@ describe("taskCreate tool model inheritance", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
@@ -2087,7 +2087,7 @@ describe("taskCreate tool model inheritance", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("test stop after model check"),
);
@@ -2367,7 +2367,7 @@ describe("pause-abort status clearing (bug fix)", () => {
navigateTree: vi.fn(),
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockReturnValueOnce(disposePromise);
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
@@ -2419,7 +2419,7 @@ describe("stuck task detector integration", () => {
navigateTree: vi.fn(),
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockReturnValueOnce(disposePromise);
const task: Task = { id: "FN-001", description: "test", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
@@ -2511,7 +2511,7 @@ describe("specifyTask — status restore failure diagnostics", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
const promptWithFallbackMock = promptWithFallback as ReturnType<typeof vi.fn>;
promptWithFallbackMock.mockReturnValueOnce(disposePromise);
@@ -2563,7 +2563,7 @@ describe("specifyTask — status restore failure diagnostics", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
const promptWithFallbackMock = promptWithFallback as ReturnType<typeof vi.fn>;
promptWithFallbackMock.mockReturnValueOnce(disposePromise);
@@ -2619,7 +2619,7 @@ describe("specifyTask — status restore failure diagnostics", () => {
},
});
const { promptWithFallback } = await import("./pi.js");
const { promptWithFallback } = await import("../pi.js");
const promptWithFallbackMock = promptWithFallback as ReturnType<typeof vi.fn>;
promptWithFallbackMock
.mockRejectedValueOnce(new Error("429 Too Many Requests"))

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { isUsageLimitError, UsageLimitPauser, checkSessionError } from "./usage-limit-detector.js";
import { isUsageLimitError, UsageLimitPauser, checkSessionError } from "../usage-limit-detector.js";
// ── isUsageLimitError classification tests ───────────────────────────

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { generateWorktreeName, ADJECTIVES, NOUNS } from "./worktree-names.js";
import { generateWorktreeName, ADJECTIVES, NOUNS } from "../worktree-names.js";
describe("generateWorktreeName", () => {
let tempDir: string;

View File

@@ -52,7 +52,7 @@ import {
cleanupOrphanedWorktrees,
reapOrphanWorktrees,
scanOrphanedBranches,
} from "./worktree-pool.js";
} from "../worktree-pool.js";
import { execSync } from "node:child_process";
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
import type { Task, Column } from "@fusion/core";

View File

@@ -1,6 +0,0 @@
import { AuthStorage } from "@mariozechner/pi-coding-agent";
export declare function getFusionAuthPath(home?: string): string;
export declare function getFusionModelsPath(home?: string): string;
export declare function getModelRegistryModelsPath(home?: string): string;
export declare function createFusionAuthStorage(): AuthStorage;
//# sourceMappingURL=auth-storage.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"auth-storage.d.ts","sourceRoot":"","sources":["auth-storage.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAiB5D,wBAAgB,iBAAiB,CAAC,IAAI,SAAe,GAAG,MAAM,CAE7D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,SAAe,GAAG,MAAM,CAE/D;AAgBD,wBAAgB,0BAA0B,CAAC,IAAI,SAAe,GAAG,MAAM,CAOtE;AAmDD,wBAAgB,uBAAuB,IAAI,WAAW,CA6CrD"}

View File

@@ -1,116 +0,0 @@
/* eslint-env node */
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { AuthStorage } from "@mariozechner/pi-coding-agent";
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
function getHomeDir() {
return globalThis.process.env.HOME || globalThis.process.env.USERPROFILE || homedir();
}
export function getFusionAuthPath(home = getHomeDir()) {
return join(home, ".fusion", "agent", "auth.json");
}
export function getFusionModelsPath(home = getHomeDir()) {
return join(home, ".fusion", "agent", "models.json");
}
function getLegacyAuthPaths(home = getHomeDir()) {
return [
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
}
function getLegacyModelsPaths(home = getHomeDir()) {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = getHomeDir()) {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readLegacyCredentials(authPaths = getLegacyAuthPaths()) {
const credentials = {};
for (const authPath of authPaths) {
if (!existsSync(authPath)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8"));
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] ??= credential;
}
}
catch {
// Ignore invalid legacy auth files and continue with other candidates.
}
}
return credentials;
}
function resolveStoredApiKey(key) {
if (!key)
return undefined;
return globalThis.process.env[key] ?? key;
}
function resolveOAuthApiKey(providerId, credential) {
if (credential.type !== "oauth" ||
typeof credential.access !== "string" ||
typeof credential.refresh !== "string" ||
typeof credential.expires !== "number" ||
Date.now() >= credential.expires) {
return undefined;
}
return getOAuthProvider(providerId)?.getApiKey(credential);
}
function resolveStoredCredentialApiKey(providerId, credential) {
if (credential?.type === "api_key") {
return resolveStoredApiKey(credential.key);
}
if (credential?.type === "oauth") {
return resolveOAuthApiKey(providerId, credential);
}
return undefined;
}
export function createFusionAuthStorage() {
const primary = AuthStorage.create(getFusionAuthPath());
let legacyCredentials = readLegacyCredentials();
return new Proxy(primary, {
get(target, prop, receiver) {
if (prop === "reload") {
return () => {
target.reload();
legacyCredentials = readLegacyCredentials();
};
}
if (prop === "get") {
return (provider) => target.get(provider) ?? legacyCredentials[provider];
}
if (prop === "has") {
return (provider) => target.has(provider) || provider in legacyCredentials;
}
if (prop === "hasAuth") {
return (provider) => target.hasAuth(provider) || Boolean(legacyCredentials[provider]);
}
if (prop === "getAll") {
return () => ({ ...legacyCredentials, ...target.getAll() });
}
if (prop === "list") {
return () => Array.from(new Set([...Object.keys(legacyCredentials), ...target.list()]));
}
if (prop === "getApiKey") {
return async (provider) => {
const primaryKey = await target.getApiKey(provider);
if (primaryKey)
return primaryKey;
return resolveStoredCredentialApiKey(provider, legacyCredentials[provider]);
};
}
return Reflect.get(target, prop, receiver);
},
});
}
//# sourceMappingURL=auth-storage.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"auth-storage.js","sourceRoot":"","sources":["auth-storage.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAY7D,SAAS,UAAU;IACjB,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;AAClE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAAG,UAAU,EAAE;IACnD,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,WAAW,CAAC,CAAC;AACrD,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,IAAI,GAAG,UAAU,EAAE;IACrD,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC;AACvD,CAAC;AAED,SAAS,kBAAkB,CAAC,IAAI,GAAG,UAAU,EAAE;IAC7C,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,CAAC;QACvC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,WAAW,CAAC;KAC/B,CAAC;AACJ,CAAC;AAED,SAAS,oBAAoB,CAAC,IAAI,GAAG,UAAU,EAAE;IAC/C,OAAO;QACL,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,CAAC;QACzC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,aAAa,CAAC;KACjC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,IAAI,GAAG,UAAU,EAAE;IAC5D,MAAM,gBAAgB,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;IACnD,IAAI,UAAU,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACjC,OAAO,gBAAgB,CAAC;IAC1B,CAAC;IAED,OAAO,oBAAoB,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC,IAAI,gBAAgB,CAAC;AACrG,CAAC;AAED,SAAS,qBAAqB,CAAC,SAAS,GAAG,kBAAkB,EAAE;IAC7D,MAAM,WAAW,GAAqC,EAAE,CAAC;IAEzD,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1B,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAqC,CAAC;YAC/F,KAAK,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC5D,WAAW,CAAC,QAAQ,CAAC,KAAK,UAAU,CAAC;YACvC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,uEAAuE;QACzE,CAAC;IACH,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC;AAED,SAAS,mBAAmB,CAAC,GAAuB;IAClD,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC;AACjC,CAAC;AAED,SAAS,kBAAkB,CAAC,UAAkB,EAAE,UAA4B;IAC1E,IACE,UAAU,CAAC,IAAI,KAAK,OAAO;QAC3B,OAAO,UAAU,CAAC,MAAM,KAAK,QAAQ;QACrC,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ;QACtC,OAAO,UAAU,CAAC,OAAO,KAAK,QAAQ;QACtC,IAAI,CAAC,GAAG,EAAE,IAAI,UAAU,CAAC,OAAO,EAChC,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,OAAO,gBAAgB,CAAC,UAAU,CAAC,EAAE,SAAS,CAAC,UAA8B,CAAC,CAAC;AACjF,CAAC;AAED,SAAS,6BAA6B,CAAC,UAAkB,EAAE,UAAwC;IACjG,IAAI,UAAU,EAAE,IAAI,KAAK,SAAS,EAAE,CAAC;QACnC,OAAO,mBAAmB,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,UAAU,EAAE,IAAI,KAAK,OAAO,EAAE,CAAC;QACjC,OAAO,kBAAkB,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,uBAAuB;IACrC,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACxD,IAAI,iBAAiB,GAAG,qBAAqB,EAAE,CAAC;IAEhD,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE;QACxB,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ;YACxB,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,EAAE;oBACV,MAAM,CAAC,MAAM,EAAE,CAAC;oBAChB,iBAAiB,GAAG,qBAAqB,EAAE,CAAC;gBAC9C,CAAC,CAAC;YACJ,CAAC;YAED,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;gBACnB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,iBAAiB,CAAC,QAAQ,CAAC,CAAC;YACnF,CAAC;YAED,IAAI,IAAI,KAAK,KAAK,EAAE,CAAC;gBACnB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,QAAQ,IAAI,iBAAiB,CAAC;YACrF,CAAC;YAED,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;gBACvB,OAAO,CAAC,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,OAAO,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;YAChG,CAAC;YAED,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACtB,OAAO,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,iBAAiB,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;YAC9D,CAAC;YAED,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;gBACpB,OAAO,GAAG,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;YAC1F,CAAC;YAED,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;gBACzB,OAAO,KAAK,EAAE,QAAgB,EAAE,EAAE;oBAChC,MAAM,UAAU,GAAG,MAAM,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;oBACpD,IAAI,UAAU;wBAAE,OAAO,UAAU,CAAC;oBAElC,OAAO,6BAA6B,CAAC,QAAQ,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC9E,CAAC,CAAC;YACJ,CAAC;YAED,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC;QAC7C,CAAC;KACF,CAAgB,CAAC;AACpB,CAAC"}

View File

@@ -1,26 +0,0 @@
/**
* Context limit error detection.
*
* Classifies errors from LLM providers that indicate the conversation context
* has grown too large for the model's window. Used by the executor to trigger
* compact-and-resume recovery before falling back to kill/requeue.
*
* Patterns are intentionally conservative — we only match errors that
* explicitly reference context/token overflow, NOT generic rate limits or
* server errors (those are handled by usage-limit-detector and transient-error-detector).
*/
/**
* Check if an error message indicates a context-window overflow.
*
* Returns true only when the message explicitly references context overflow
* from a known LLM provider pattern. Returns false for:
* - Rate limit errors (handled by usage-limit-detector)
* - Transient network errors (handled by transient-error-detector)
* - Generic "limit exceeded" without context keywords (false positive prevention)
* - "Aborted" errors without context signal
*
* @param message — The error message string to classify
* @returns true if the message indicates a context overflow
*/
export declare function isContextLimitError(message: string): boolean;
//# sourceMappingURL=context-limit-detector.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"context-limit-detector.d.ts","sourceRoot":"","sources":["context-limit-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAoCH;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG5D"}

View File

@@ -1,63 +0,0 @@
/**
* Context limit error detection.
*
* Classifies errors from LLM providers that indicate the conversation context
* has grown too large for the model's window. Used by the executor to trigger
* compact-and-resume recovery before falling back to kill/requeue.
*
* Patterns are intentionally conservative — we only match errors that
* explicitly reference context/token overflow, NOT generic rate limits or
* server errors (those are handled by usage-limit-detector and transient-error-detector).
*/
/** Patterns that indicate a context-window overflow from the LLM provider. */
const CONTEXT_OVERFLOW_PATTERNS = [
// Anthropic: "prompt is too long: X tokens > Y maximum"
/prompt is too long/i,
// OpenAI (Completions & Responses): "exceeds the context window"
/exceeds?\s+the\s+context\s+window/i,
// Google Gemini: "input token count exceeds the maximum"
/input token count exceeds/i,
// xAI (Grok): "maximum prompt length is X but request contains Y"
/maximum prompt length/i,
// Groq: "reduce the length of the messages"
/reduce the length of the messages/i,
// Mistral: "too large for model with Y maximum context length"
/too large for model with.*maximum context length/i,
// OpenRouter (all backends): "maximum context length is X tokens"
/maximum context length is \d+ tokens/i,
// llama.cpp: "exceeds the available context size"
/exceeds?\s+the\s+available\s+context\s+size/i,
// LM Studio: "greater than the context length"
/greater than the context length/i,
// Kimi: "exceeded model token limit"
/exceeded model token limit/i,
// Generic catch-all: "context length exceeded" / "context window exceeded"
/context (?:length|window|size) exceeded/i,
// Token limit patterns with context keywords
/token limit.*context/i,
/too many tokens/i,
// Anthropic variant: "messages with that many tokens would exceed"
/tokens? would exceed/i,
// Provider JSON error envelope variant: "context window exceeds limit (2013)"
// Matches when "context window" and "exceeds" appear together (order-flexible)
/context\s+window\s+exceeds/i,
];
/**
* Check if an error message indicates a context-window overflow.
*
* Returns true only when the message explicitly references context overflow
* from a known LLM provider pattern. Returns false for:
* - Rate limit errors (handled by usage-limit-detector)
* - Transient network errors (handled by transient-error-detector)
* - Generic "limit exceeded" without context keywords (false positive prevention)
* - "Aborted" errors without context signal
*
* @param message — The error message string to classify
* @returns true if the message indicates a context overflow
*/
export function isContextLimitError(message) {
if (!message)
return false;
return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(message));
}
//# sourceMappingURL=context-limit-detector.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"context-limit-detector.js","sourceRoot":"","sources":["context-limit-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,8EAA8E;AAC9E,MAAM,yBAAyB,GAAa;IAC1C,wDAAwD;IACxD,qBAAqB;IACrB,iEAAiE;IACjE,oCAAoC;IACpC,yDAAyD;IACzD,4BAA4B;IAC5B,kEAAkE;IAClE,wBAAwB;IACxB,4CAA4C;IAC5C,oCAAoC;IACpC,+DAA+D;IAC/D,mDAAmD;IACnD,kEAAkE;IAClE,uCAAuC;IACvC,kDAAkD;IAClD,8CAA8C;IAC9C,+CAA+C;IAC/C,kCAAkC;IAClC,qCAAqC;IACrC,6BAA6B;IAC7B,2EAA2E;IAC3E,0CAA0C;IAC1C,6CAA6C;IAC7C,uBAAuB;IACvB,kBAAkB;IAClB,mEAAmE;IACnE,uBAAuB;IACvB,8EAA8E;IAC9E,+EAA+E;IAC/E,6BAA6B;CAC9B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAe;IACjD,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IAC3B,OAAO,yBAAyB,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;AAC5E,CAAC"}

View File

@@ -17,7 +17,7 @@ export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopi
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
export { aiMergeTask, type MergerOptions } from "./merger.js";
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
export { createFnAgent, promptWithFallback, type AgentOptions, type AgentResult } from "./pi.js";
export { createFnAgent, promptWithFallback, describeModel, type AgentOptions, type AgentResult } from "./pi.js";
export {
resolveSessionSkills,
createSkillsOverrideFromSelection,

View File

@@ -1,471 +0,0 @@
/**
* Unit tests for IpcHost — the parent-side IPC handler that sends commands
* to a child process worker and correlates responses.
*
* Coverage:
* - Constructor: listener setup, options, initial state
* - sendCommand: serialization, response correlation (OK/ERROR/PONG), timeout, disconnection
* - ping: convenience wrapper for sendCommand("PING")
* - Event forwarding: worker events emitted on IpcHost
* - Malformed/unknown messages: silently ignored
* - Disconnection cascade: child error/exit/disconnect → pending commands rejected
* - disconnect(): explicit cleanup and listener removal
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import type { ChildProcess } from "node:child_process";
import { IpcHost } from "./ipc-host.js";
import { OK, ERROR, PONG, TASK_CREATED } from "./ipc-protocol.js";
// ── Mock logger to suppress console output ──────────────────────────────
vi.mock("../logger.js", () => ({
ipcLog: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// ── Mock ChildProcess factory ───────────────────────────────────────────
/**
* Creates a mock ChildProcess that is an EventEmitter with the required
* properties for IpcHost: `send`, `connected`, `disconnect`.
*/
function createMockChildProcess(
overrides: {
connected?: boolean;
send?: ((...args: any[]) => any) | undefined;
} = {}
): ChildProcess {
const emitter = new EventEmitter();
const mock = emitter as unknown as ChildProcess & EventEmitter;
// Default: connected with a working send
Object.defineProperty(mock, "connected", {
get: () => overrides.connected ?? true,
configurable: true,
});
if (overrides.send === undefined && !("send" in overrides)) {
// Default: working send that invokes callback with no error
(mock as any).send = vi.fn((...args: any[]) => {
const callback = args.find((a: unknown) => typeof a === "function");
if (callback) callback(null);
return true;
});
} else {
(mock as any).send = overrides.send;
}
(mock as any).disconnect = vi.fn();
(mock as any).kill = vi.fn();
(mock as any).killed = false;
(mock as any).pid = 12345;
return mock;
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("IpcHost", () => {
let child: ChildProcess & EventEmitter;
let host: IpcHost;
beforeEach(() => {
child = createMockChildProcess() as ChildProcess & EventEmitter;
host = new IpcHost(child);
});
afterEach(() => {
host.removeAllListeners();
});
// ── Constructor & initial state ──────────────────────────────────────
describe("constructor and initial state", () => {
it("registers listeners on child process for message, error, exit, disconnect events", () => {
// EventEmitter.listenerCount shows listeners were added
expect(child.listenerCount("message")).toBeGreaterThanOrEqual(1);
expect(child.listenerCount("error")).toBeGreaterThanOrEqual(1);
expect(child.listenerCount("exit")).toBeGreaterThanOrEqual(1);
expect(child.listenerCount("disconnect")).toBeGreaterThanOrEqual(1);
});
it("isConnected() returns true when child is connected and not disconnected", () => {
expect(host.isConnected()).toBe(true);
});
it("isConnected() returns false after disconnection", () => {
child.emit("disconnect");
expect(host.isConnected()).toBe(false);
});
it("getChildProcess() returns the child process instance", () => {
expect(host.getChildProcess()).toBe(child);
});
it("getPendingCommandCount() returns 0 initially", () => {
expect(host.getPendingCommandCount()).toBe(0);
});
it("accepts custom commandTimeoutMs option", () => {
// We verify this indirectly in the timeout test in Step 2
const customHost = new IpcHost(child, { commandTimeoutMs: 500 });
expect(customHost).toBeInstanceOf(IpcHost);
customHost.removeAllListeners();
});
});
// ── sendCommand and response correlation ────────────────────────────
describe("sendCommand", () => {
it("sends a valid IpcMessage via childProcess.send() with correct type, unique id, and payload", async () => {
const sendFn = child.send as ReturnType<typeof vi.fn>;
const commandPromise = host.sendCommand("GET_STATUS", { foo: "bar" });
// Extract the message from the mock send call
expect(sendFn).toHaveBeenCalledTimes(1);
const sentMessage = sendFn.mock.calls[0][0];
expect(sentMessage.type).toBe("GET_STATUS");
expect(typeof sentMessage.id).toBe("string");
expect(sentMessage.id.length).toBeGreaterThan(0);
expect(sentMessage.payload).toEqual({ foo: "bar" });
// Respond to resolve the promise
child.emit("message", { type: OK, id: sentMessage.id, payload: { data: "result" } });
await expect(commandPromise).resolves.toBe("result");
});
it("resolves with data when child responds with OK matching the correlation ID", async () => {
const sendFn = child.send as ReturnType<typeof vi.fn>;
const promise = host.sendCommand("GET_METRICS", {});
const sentId = sendFn.mock.calls[0][0].id;
child.emit("message", { type: OK, id: sentId, payload: { data: { tasks: 5 } } });
await expect(promise).resolves.toEqual({ tasks: 5 });
});
it("rejects with an Error (including message and code) when child responds with ERROR", async () => {
const sendFn = child.send as ReturnType<typeof vi.fn>;
const promise = host.sendCommand("GET_STATUS", {});
const sentId = sendFn.mock.calls[0][0].id;
child.emit("message", {
type: ERROR,
id: sentId,
payload: { message: "Something went wrong", code: "HANDLER_ERROR" },
});
await expect(promise).rejects.toThrow("Something went wrong");
try {
await promise;
} catch (err: any) {
expect(err.code).toBe("HANDLER_ERROR");
}
});
it("resolves with pong payload when child responds with PONG", async () => {
const sendFn = child.send as ReturnType<typeof vi.fn>;
const promise = host.sendCommand("PING", {});
const sentId = sendFn.mock.calls[0][0].id;
child.emit("message", {
type: PONG,
id: sentId,
payload: { timestamp: "2026-04-01T00:00:00.000Z" },
});
await expect(promise).resolves.toEqual({ timestamp: "2026-04-01T00:00:00.000Z" });
});
it("rejects after timeout using fake timers", async () => {
vi.useFakeTimers();
try {
const promise = host.sendCommand("GET_STATUS", {}, 1000);
// Advance past the timeout
vi.advanceTimersByTime(1001);
await expect(promise).rejects.toThrow("timed out after 1000ms");
} finally {
vi.useRealTimers();
}
});
it("uses custom commandTimeoutMs when no per-call override provided", async () => {
vi.useFakeTimers();
try {
const shortHost = new IpcHost(child, { commandTimeoutMs: 200 });
const promise = shortHost.sendCommand("GET_STATUS", {});
vi.advanceTimersByTime(201);
await expect(promise).rejects.toThrow("timed out after 200ms");
shortHost.removeAllListeners();
} finally {
vi.useRealTimers();
}
});
it("clears pending command on successful response (getPendingCommandCount returns 0)", async () => {
const sendFn = child.send as ReturnType<typeof vi.fn>;
const promise = host.sendCommand("GET_STATUS", {});
expect(host.getPendingCommandCount()).toBe(1);
const sentId = sendFn.mock.calls[0][0].id;
child.emit("message", { type: OK, id: sentId, payload: { data: null } });
await promise;
expect(host.getPendingCommandCount()).toBe(0);
});
it("rejects immediately when IPC is already disconnected", async () => {
child.emit("disconnect");
await expect(host.sendCommand("GET_STATUS", {})).rejects.toThrow(
"Cannot send command: IPC channel disconnected"
);
});
it("rejects when childProcess.send is undefined (no IPC channel)", async () => {
const noSendChild = createMockChildProcess({ send: undefined }) as ChildProcess & EventEmitter;
const noSendHost = new IpcHost(noSendChild);
await expect(noSendHost.sendCommand("GET_STATUS", {})).rejects.toThrow(
"Child process does not have IPC channel"
);
noSendHost.removeAllListeners();
});
it("rejects when childProcess.send callback returns an error", async () => {
const errChild = createMockChildProcess({
send: vi.fn((...args: any[]) => {
// Find the callback argument (last function arg)
const callback = args.find((a: unknown) => typeof a === "function");
if (callback) callback(new Error("Send failed"));
return false;
}) as any,
}) as ChildProcess & EventEmitter;
const errHost = new IpcHost(errChild);
await expect(errHost.sendCommand("GET_STATUS", {})).rejects.toThrow("Failed to send command: Send failed");
errHost.removeAllListeners();
});
});
// ── ping ─────────────────────────────────────────────────────────────
describe("ping", () => {
it("calls sendCommand('PING', {}, 5000) and resolves with timestamp", async () => {
const sendFn = child.send as ReturnType<typeof vi.fn>;
const promise = host.ping();
const sentMessage = sendFn.mock.calls[0][0];
expect(sentMessage.type).toBe("PING");
child.emit("message", {
type: PONG,
id: sentMessage.id,
payload: { timestamp: "2026-04-01T12:00:00.000Z" },
});
const result = await promise;
expect(result).toEqual({ timestamp: "2026-04-01T12:00:00.000Z" });
});
});
// ── Event forwarding ────────────────────────────────────────────────
describe("event forwarding", () => {
it("incoming event messages are emitted on IpcHost with the event type and payload", () => {
const handler = vi.fn();
host.on(TASK_CREATED, handler);
const payload = { task: { id: "KB-001", title: "Test" } };
child.emit("message", {
type: TASK_CREATED,
id: "evt-1",
payload,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(payload);
});
it('generic "message" event is also emitted for every incoming event message', () => {
const handler = vi.fn();
host.on("message", handler);
const message = { type: TASK_CREATED, id: "evt-2", payload: { task: {} } };
child.emit("message", message);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(message);
});
});
// ── Malformed messages ──────────────────────────────────────────────
describe("malformed messages", () => {
it("silently ignores message missing type", () => {
const handler = vi.fn();
host.on("message", handler);
// Missing type
child.emit("message", { id: "x", payload: {} });
expect(handler).not.toHaveBeenCalled();
});
it("silently ignores message missing id", () => {
const handler = vi.fn();
host.on("message", handler);
child.emit("message", { type: "SOME_TYPE", payload: {} });
expect(handler).not.toHaveBeenCalled();
});
it("silently ignores message missing payload", () => {
const handler = vi.fn();
host.on("message", handler);
child.emit("message", { type: "SOME_TYPE", id: "x" });
expect(handler).not.toHaveBeenCalled();
});
it("silently ignores non-object messages", () => {
const handler = vi.fn();
host.on("message", handler);
child.emit("message", "not an object");
child.emit("message", null);
child.emit("message", 42);
expect(handler).not.toHaveBeenCalled();
});
it("ignores response for unknown correlation ID without crashing", () => {
// Should not throw
child.emit("message", {
type: OK,
id: "unknown-correlation-id",
payload: { data: "phantom" },
});
expect(host.getPendingCommandCount()).toBe(0);
});
});
// ── Disconnection cascade ───────────────────────────────────────────
describe("disconnection", () => {
it("child error event rejects all pending commands with 'IPC disconnected' error and emits 'disconnect'", async () => {
vi.useFakeTimers();
try {
const disconnectHandler = vi.fn();
host.on("disconnect", disconnectHandler);
const promise = host.sendCommand("GET_STATUS", {});
expect(host.getPendingCommandCount()).toBe(1);
child.emit("error", new Error("child crash"));
await expect(promise).rejects.toThrow("IPC disconnected");
expect(host.getPendingCommandCount()).toBe(0);
expect(disconnectHandler).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("child exit event (with code) triggers disconnection", async () => {
vi.useFakeTimers();
try {
const disconnectHandler = vi.fn();
host.on("disconnect", disconnectHandler);
const promise = host.sendCommand("GET_STATUS", {});
child.emit("exit", 1, null);
await expect(promise).rejects.toThrow("IPC disconnected");
expect(disconnectHandler).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("child exit event (with signal) triggers disconnection", async () => {
vi.useFakeTimers();
try {
const disconnectHandler = vi.fn();
host.on("disconnect", disconnectHandler);
const promise = host.sendCommand("GET_STATUS", {});
child.emit("exit", null, "SIGTERM");
await expect(promise).rejects.toThrow("IPC disconnected");
expect(disconnectHandler).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("child disconnect event triggers disconnection", async () => {
vi.useFakeTimers();
try {
const disconnectHandler = vi.fn();
host.on("disconnect", disconnectHandler);
const promise = host.sendCommand("GET_STATUS", {});
child.emit("disconnect");
await expect(promise).rejects.toThrow("IPC disconnected");
expect(disconnectHandler).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("double disconnection is idempotent (no re-reject or double-emit)", () => {
const disconnectHandler = vi.fn();
host.on("disconnect", disconnectHandler);
child.emit("disconnect");
child.emit("disconnect");
expect(disconnectHandler).toHaveBeenCalledTimes(1);
});
it("disconnect() method rejects pending commands, calls childProcess.disconnect(), removes all listeners", async () => {
vi.useFakeTimers();
try {
const promise = host.sendCommand("GET_STATUS", {});
expect(host.getPendingCommandCount()).toBe(1);
host.disconnect();
await expect(promise).rejects.toThrow("IPC disconnected");
expect(host.getPendingCommandCount()).toBe(0);
expect((child as any).disconnect).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("disconnect() skips childProcess.disconnect() when already disconnected", () => {
// Simulate child already disconnected
Object.defineProperty(child, "connected", {
get: () => false,
configurable: true,
});
host.disconnect();
// disconnect() should not call child.disconnect() since connected is false
expect((child as any).disconnect).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,175 +0,0 @@
import { describe, it, expect } from "vitest";
import {
START_RUNTIME,
STOP_RUNTIME,
GET_STATUS,
GET_METRICS,
GET_TASK_STORE,
GET_SCHEDULER,
PING,
OK,
ERROR,
PONG,
TASK_CREATED,
TASK_MOVED,
TASK_UPDATED,
ERROR_EVENT,
HEALTH_CHANGED,
isIpcCommand,
isIpcResponse,
isIpcEvent,
createCommand,
createResponse,
createEvent,
generateCorrelationId,
} from "./ipc-protocol.js";
describe("IPC Protocol", () => {
describe("constants", () => {
it("should export all command types", () => {
expect(START_RUNTIME).toBe("START_RUNTIME");
expect(STOP_RUNTIME).toBe("STOP_RUNTIME");
expect(GET_STATUS).toBe("GET_STATUS");
expect(GET_METRICS).toBe("GET_METRICS");
expect(GET_TASK_STORE).toBe("GET_TASK_STORE");
expect(GET_SCHEDULER).toBe("GET_SCHEDULER");
expect(PING).toBe("PING");
});
it("should export all response types", () => {
expect(OK).toBe("OK");
expect(ERROR).toBe("ERROR");
expect(PONG).toBe("PONG");
});
it("should export all event types", () => {
expect(TASK_CREATED).toBe("TASK_CREATED");
expect(TASK_MOVED).toBe("TASK_MOVED");
expect(TASK_UPDATED).toBe("TASK_UPDATED");
expect(ERROR_EVENT).toBe("ERROR_EVENT");
expect(HEALTH_CHANGED).toBe("HEALTH_CHANGED");
});
it("should have distinct ERROR and ERROR_EVENT values", () => {
expect(ERROR).toBe("ERROR");
expect(ERROR_EVENT).toBe("ERROR_EVENT");
expect(ERROR).not.toBe(ERROR_EVENT);
});
});
describe("isIpcCommand", () => {
it("should return true for command types", () => {
expect(isIpcCommand({ type: START_RUNTIME, id: "1", payload: {} })).toBe(true);
expect(isIpcCommand({ type: STOP_RUNTIME, id: "1", payload: {} })).toBe(true);
expect(isIpcCommand({ type: GET_STATUS, id: "1", payload: {} })).toBe(true);
expect(isIpcCommand({ type: PING, id: "1", payload: {} })).toBe(true);
});
it("should return false for response types", () => {
expect(isIpcCommand({ type: OK, id: "1", payload: {} })).toBe(false);
expect(isIpcCommand({ type: ERROR, id: "1", payload: {} })).toBe(false);
expect(isIpcCommand({ type: PONG, id: "1", payload: {} })).toBe(false);
});
it("should return false for event types", () => {
expect(isIpcCommand({ type: TASK_CREATED, id: "1", payload: {} })).toBe(false);
expect(isIpcCommand({ type: HEALTH_CHANGED, id: "1", payload: {} })).toBe(false);
});
});
describe("isIpcResponse", () => {
it("should return true for response types", () => {
expect(isIpcResponse({ type: OK, id: "1", payload: {} })).toBe(true);
expect(isIpcResponse({ type: ERROR, id: "1", payload: {} })).toBe(true);
expect(isIpcResponse({ type: PONG, id: "1", payload: {} })).toBe(true);
});
it("should return false for command types", () => {
expect(isIpcResponse({ type: START_RUNTIME, id: "1", payload: {} })).toBe(false);
expect(isIpcResponse({ type: PING, id: "1", payload: {} })).toBe(false);
});
it("should return false for event types", () => {
expect(isIpcResponse({ type: TASK_CREATED, id: "1", payload: {} })).toBe(false);
});
});
describe("isIpcEvent", () => {
it("should return true for event types", () => {
expect(isIpcEvent({ type: TASK_CREATED, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: TASK_MOVED, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: TASK_UPDATED, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: ERROR_EVENT, id: "1", payload: {} })).toBe(true);
expect(isIpcEvent({ type: HEALTH_CHANGED, id: "1", payload: {} })).toBe(true);
});
it("should return false for command types", () => {
expect(isIpcEvent({ type: START_RUNTIME, id: "1", payload: {} })).toBe(false);
expect(isIpcEvent({ type: PING, id: "1", payload: {} })).toBe(false);
});
it("should return false for response types", () => {
expect(isIpcEvent({ type: OK, id: "1", payload: {} })).toBe(false);
expect(isIpcEvent({ type: ERROR, id: "1", payload: {} })).toBe(false);
});
});
describe("createCommand", () => {
it("should create a command message", () => {
const payload = { config: { projectId: "test" } };
const message = createCommand(START_RUNTIME, "cmd-1", payload);
expect(message).toEqual({
type: START_RUNTIME,
id: "cmd-1",
payload,
});
});
});
describe("createResponse", () => {
it("should create a response message", () => {
const payload = { data: { status: "active" } };
const message = createResponse(OK, "cmd-1", payload);
expect(message).toEqual({
type: OK,
id: "cmd-1",
payload,
});
});
});
describe("createEvent", () => {
it("should create an event message", () => {
const payload = { task: { id: "KB-001" } };
const message = createEvent(TASK_CREATED, "evt-1", payload);
expect(message).toEqual({
type: TASK_CREATED,
id: "evt-1",
payload,
});
});
});
describe("generateCorrelationId", () => {
it("should generate unique IDs", () => {
const id1 = generateCorrelationId();
const id2 = generateCorrelationId();
expect(id1).toBeDefined();
expect(id2).toBeDefined();
expect(id1).not.toBe(id2);
});
it("should generate string IDs with timestamp and random parts", () => {
const id = generateCorrelationId();
const parts = id.split("-");
expect(parts.length).toBeGreaterThanOrEqual(2);
// First part should be a timestamp (number)
expect(Number.parseInt(parts[0], 10)).not.toBeNaN();
});
});
});

View File

@@ -1,510 +0,0 @@
/**
* Unit tests for IpcWorker — the child-process-side IPC handler that receives
* commands from a host, dispatches to registered handlers, and sends responses/events.
*
* Coverage:
* - Constructor: process.send validation, listener registration, initial state
* - PING auto-response (no handler needed)
* - onCommand / offCommand: handler registration and dispatch
* - Command execution: OK response, ERROR response (Error and non-Error), NO_HANDLER, UNKNOWN_COMMAND, MALFORMED_MESSAGE
* - sendEvent / sendErrorEvent: event message construction
* - sendResponse: response message construction
* - shutdown: idempotent, suppresses further sends, emits event
* - disconnect event forwarding
* - Edge cases: process.send undefined after construction, graceful fallback
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { PING, PONG, OK, ERROR, TASK_CREATED, ERROR_EVENT } from "./ipc-protocol.js";
import { ipcLog } from "../logger.js";
// ── Mock logger to suppress console output ──────────────────────────────
vi.mock("../logger.js", () => ({
ipcLog: {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// ── Process mock utilities ──────────────────────────────────────────────
/**
* We need to mock process.send and intercept process.on("message") handlers
* without breaking the real process. Strategy:
* - Set process.send to a vi.fn() before creating IpcWorker
* - Track message handlers registered via process.on("message")
* - Simulate incoming messages by calling those handlers directly
*/
// Store the original process.send to restore after tests
const originalProcessSend = process.send;
// Track registered message/disconnect handlers so we can invoke them
let messageHandlers: Array<(msg: unknown) => void> = [];
let disconnectHandlers: Array<() => void> = [];
// Spies for process.on and process.removeAllListeners
let processOnSpy: ReturnType<typeof vi.fn>;
function setupProcessMocks() {
// Set up process.send as a mock function
process.send = vi.fn((_msg: unknown, _handle?: unknown, _options?: unknown, callback?: (err: Error | null) => void) => {
if (typeof callback === "function") callback(null);
return true;
});
messageHandlers = [];
disconnectHandlers = [];
// Intercept process.on to capture message/disconnect handlers
const originalProcessOn = process.on.bind(process);
processOnSpy = vi.fn((event: string, handler: (...args: any[]) => void) => {
if (event === "message") {
messageHandlers.push(handler);
} else if (event === "disconnect") {
disconnectHandlers.push(handler);
}
// Don't register signal handlers on real process during tests
if (event === "SIGTERM" || event === "SIGINT" || event === "uncaughtException" || event === "unhandledRejection") {
return process;
}
return originalProcessOn(event, handler);
});
process.on = processOnSpy as any;
}
function teardownProcessMocks() {
// Restore process.send
if (originalProcessSend === undefined) {
delete (process as any).send;
} else {
process.send = originalProcessSend;
}
// Remove any listeners we added during the test
for (const handler of messageHandlers) {
process.removeListener("message", handler);
}
for (const handler of disconnectHandlers) {
process.removeListener("disconnect", handler);
}
messageHandlers = [];
disconnectHandlers = [];
}
/** Simulate an incoming message from the host */
function simulateMessage(msg: unknown) {
for (const handler of messageHandlers) {
handler(msg);
}
}
/** Simulate a disconnect event */
function simulateDisconnect() {
for (const handler of disconnectHandlers) {
handler();
}
}
// ── Tests ────────────────────────────────────────────────────────────────
describe("IpcWorker", () => {
// We need to dynamically import IpcWorker after mocks are set up
let IpcWorker: typeof import("./ipc-worker.js").IpcWorker;
beforeEach(async () => {
setupProcessMocks();
// Dynamic import to get fresh module (the mock setup needs to be in place)
const mod = await import("./ipc-worker.js");
IpcWorker = mod.IpcWorker;
});
afterEach(() => {
teardownProcessMocks();
});
// ── Constructor & initial state ──────────────────────────────────────
describe("constructor and initial state", () => {
it("throws when process.send is undefined", async () => {
teardownProcessMocks(); // Remove mock
// Ensure process.send is undefined
delete (process as any).send;
expect(() => new IpcWorker()).toThrow(
"IpcWorker can only be instantiated in a forked child process"
);
// Re-set up for afterEach
setupProcessMocks();
const mod = await import("./ipc-worker.js");
IpcWorker = mod.IpcWorker;
});
it("registers listeners on process for message and disconnect events", () => {
const worker = new IpcWorker();
expect(messageHandlers.length).toBeGreaterThanOrEqual(1);
expect(disconnectHandlers.length).toBeGreaterThanOrEqual(1);
worker.removeAllListeners();
});
it("getHandlerCount() returns 0 initially", () => {
const worker = new IpcWorker();
expect(worker.getHandlerCount()).toBe(0);
worker.removeAllListeners();
});
it("isShuttingDown() returns false initially", () => {
const worker = new IpcWorker();
expect(worker.isShuttingDown()).toBe(false);
worker.removeAllListeners();
});
});
/**
* Helper: creates a worker and returns it along with its dedicated message handler.
* Also clears the process.send mock so each test starts fresh.
*/
function createWorker() {
const msgCountBefore = messageHandlers.length;
const discCountBefore = disconnectHandlers.length;
const worker = new IpcWorker();
const sendFn = process.send as ReturnType<typeof vi.fn>;
sendFn.mockClear();
// The worker's handlers are the ones added after the counts
const workerMsgHandler = messageHandlers[messageHandlers.length - 1];
const workerDiscHandler = disconnectHandlers[disconnectHandlers.length - 1];
/** Send a message to this worker's handler */
const sendMessage = (msg: unknown) => workerMsgHandler(msg);
/** Simulate disconnect for this specific worker */
const triggerDisconnect = () => workerDiscHandler?.();
/** Get all messages sent to parent via process.send since last clear */
const getSentMessages = () => sendFn.mock.calls.map((call: any[]) => call[0]);
/** Find the first sent message matching a type */
const findSent = (type: string) =>
sendFn.mock.calls.find((call: any[]) => call[0]?.type === type)?.[0];
return { worker, sendMessage, triggerDisconnect, sendFn, getSentMessages, findSent };
}
// ── PING auto-response ──────────────────────────────────────────────
describe("PING handling", () => {
it("incoming PING message automatically responds with PONG containing a timestamp", async () => {
const { worker, sendMessage, findSent } = createWorker();
sendMessage({ type: PING, id: "ping-1", payload: {} });
// handleMessage is async, give it a tick
await vi.waitFor(() => {
expect(findSent(PONG)).toBeDefined();
});
const response = findSent(PONG);
expect(response.type).toBe(PONG);
expect(response.id).toBe("ping-1");
expect(typeof response.payload.timestamp).toBe("string");
worker.removeAllListeners();
});
});
// ── Command handling ────────────────────────────────────────────────
describe("command handling", () => {
it("onCommand() registers a handler: getHandlerCount() increments", () => {
const { worker } = createWorker();
expect(worker.getHandlerCount()).toBe(0);
worker.onCommand("START_RUNTIME", async () => ({ success: true }));
expect(worker.getHandlerCount()).toBe(1);
worker.onCommand("STOP_RUNTIME", async () => {});
expect(worker.getHandlerCount()).toBe(2);
worker.removeAllListeners();
});
it("offCommand() removes a handler: getHandlerCount() decrements", () => {
const { worker } = createWorker();
worker.onCommand("START_RUNTIME", async () => {});
expect(worker.getHandlerCount()).toBe(1);
worker.offCommand("START_RUNTIME");
expect(worker.getHandlerCount()).toBe(0);
worker.removeAllListeners();
});
it("receiving a registered command invokes the handler with the message payload", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const { worker, sendMessage } = createWorker();
worker.onCommand("GET_STATUS", handler);
const payload = { detail: "test" };
sendMessage({ type: "GET_STATUS", id: "cmd-1", payload });
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledWith(payload);
});
worker.removeAllListeners();
});
it("handler returning a value sends OK response with { data: returnValue }", async () => {
const { worker, sendMessage, findSent } = createWorker();
worker.onCommand("GET_METRICS", async () => ({ tasks: 10 }));
sendMessage({ type: "GET_METRICS", id: "cmd-2", payload: {} });
await vi.waitFor(() => {
expect(findSent(OK)).toBeDefined();
});
const response = findSent(OK);
expect(response.type).toBe(OK);
expect(response.id).toBe("cmd-2");
expect(response.payload).toEqual({ data: { tasks: 10 } });
worker.removeAllListeners();
});
it("handler throwing an error sends ERROR response with { message, code: 'HANDLER_ERROR' }", async () => {
const { worker, sendMessage, findSent } = createWorker();
worker.onCommand("GET_STATUS", async () => {
throw new Error("Something broke");
});
sendMessage({ type: "GET_STATUS", id: "cmd-3", payload: {} });
await vi.waitFor(() => {
expect(findSent(ERROR)).toBeDefined();
});
const response = findSent(ERROR);
expect(response.type).toBe(ERROR);
expect(response.id).toBe("cmd-3");
expect(response.payload.message).toBe("Something broke");
expect(response.payload.code).toBe("HANDLER_ERROR");
worker.removeAllListeners();
});
it("handler throwing a non-Error value still sends ERROR response with stringified message", async () => {
const { worker, sendMessage, findSent } = createWorker();
worker.onCommand("GET_STATUS", async () => {
throw "string error";
});
sendMessage({ type: "GET_STATUS", id: "cmd-4", payload: {} });
await vi.waitFor(() => {
expect(findSent(ERROR)).toBeDefined();
});
const response = findSent(ERROR);
expect(response.type).toBe(ERROR);
expect(response.id).toBe("cmd-4");
expect(response.payload.message).toBe("string error");
worker.removeAllListeners();
});
it("receiving a command with no registered handler sends ERROR with code: 'NO_HANDLER'", async () => {
const { worker, sendMessage, findSent } = createWorker();
// Don't register any handler for START_RUNTIME
sendMessage({ type: "START_RUNTIME", id: "cmd-5", payload: {} });
await vi.waitFor(() => {
expect(findSent(ERROR)).toBeDefined();
});
const response = findSent(ERROR);
expect(response.payload.code).toBe("NO_HANDLER");
expect(response.id).toBe("cmd-5");
worker.removeAllListeners();
});
it("receiving a non-command (unknown type) sends ERROR with code: 'UNKNOWN_COMMAND'", async () => {
const { worker, sendMessage, findSent } = createWorker();
sendMessage({ type: "TOTALLY_UNKNOWN", id: "cmd-6", payload: {} });
await vi.waitFor(() => {
expect(findSent(ERROR)).toBeDefined();
});
const response = findSent(ERROR);
expect(response.payload.code).toBe("UNKNOWN_COMMAND");
expect(response.id).toBe("cmd-6");
worker.removeAllListeners();
});
it("receiving a malformed message (not a valid IpcMessage) sends ERROR with code: 'MALFORMED_MESSAGE'", async () => {
const { worker, sendMessage, findSent } = createWorker();
sendMessage({ noType: true }); // Missing type, id, payload
await vi.waitFor(() => {
expect(findSent(ERROR)).toBeDefined();
});
const response = findSent(ERROR);
expect(response.payload.code).toBe("MALFORMED_MESSAGE");
worker.removeAllListeners();
});
});
// ── sendEvent / sendErrorEvent ──────────────────────────────────────
describe("sendEvent and sendErrorEvent", () => {
it("sendEvent() sends an IpcMessage with the given event type, a generated correlation ID, and payload", () => {
const { worker, sendFn } = createWorker();
worker.sendEvent(TASK_CREATED, { task: { id: "KB-001" } });
expect(sendFn).toHaveBeenCalledTimes(1);
const msg = sendFn.mock.calls[0][0];
expect(msg.type).toBe(TASK_CREATED);
expect(typeof msg.id).toBe("string");
expect(msg.id.length).toBeGreaterThan(0);
expect(msg.payload).toEqual({ task: { id: "KB-001" } });
worker.removeAllListeners();
});
it("sendErrorEvent() sends an ERROR_EVENT typed message with error message and code", () => {
const { worker, sendFn } = createWorker();
const err = new Error("Runtime crashed");
(err as any).code = "RUNTIME_ERROR";
worker.sendErrorEvent(err);
expect(sendFn).toHaveBeenCalledTimes(1);
const msg = sendFn.mock.calls[0][0];
expect(msg.type).toBe(ERROR_EVENT);
expect(msg.payload).toEqual({
message: "Runtime crashed",
code: "RUNTIME_ERROR",
});
worker.removeAllListeners();
});
});
// ── Shutdown ────────────────────────────────────────────────────────
describe("shutdown", () => {
it("sets isShuttingDown() to true", () => {
const { worker } = createWorker();
expect(worker.isShuttingDown()).toBe(false);
worker.shutdown();
expect(worker.isShuttingDown()).toBe(true);
worker.removeAllListeners();
});
it("sends a SHUTDOWN message to parent via process.send", () => {
const { worker, sendFn } = createWorker();
worker.shutdown();
expect(sendFn).toHaveBeenCalledTimes(1);
const msg = sendFn.mock.calls[0][0];
expect(msg.type).toBe("SHUTDOWN");
expect(typeof msg.id).toBe("string");
expect(msg.payload).toEqual({});
worker.removeAllListeners();
});
it("logs warning when process.send throws during shutdown", () => {
const { worker, sendFn } = createWorker();
vi.mocked(ipcLog.warn).mockClear();
sendFn.mockImplementation(() => {
throw new Error("channel closed");
});
worker.shutdown();
expect(vi.mocked(ipcLog.warn)).toHaveBeenCalledWith(
expect.stringContaining("Failed to send SHUTDOWN message to parent: channel closed"),
);
expect(worker.isShuttingDown()).toBe(true);
worker.removeAllListeners();
});
it('emits "shutdown" event on the IpcWorker instance', () => {
const { worker } = createWorker();
const handler = vi.fn();
worker.on("shutdown", handler);
worker.shutdown();
expect(handler).toHaveBeenCalledTimes(1);
worker.removeAllListeners();
});
it("is idempotent (calling twice only sends one SHUTDOWN message)", () => {
const { worker, sendFn } = createWorker();
worker.shutdown();
worker.shutdown();
// Only one SHUTDOWN message should be sent
expect(sendFn).toHaveBeenCalledTimes(1);
worker.removeAllListeners();
});
it("after shutdown(), sendEvent() and sendResponse() are no-ops", () => {
const { worker, sendFn } = createWorker();
worker.shutdown();
sendFn.mockClear();
worker.sendEvent(TASK_CREATED, { task: {} });
worker.sendResponse(OK, "some-id", { data: null });
expect(sendFn).not.toHaveBeenCalled();
worker.removeAllListeners();
});
});
// ── Disconnect ──────────────────────────────────────────────────────
describe("disconnect", () => {
it('process disconnect event emits "disconnect" on IpcWorker', () => {
const { worker, triggerDisconnect } = createWorker();
const handler = vi.fn();
worker.on("disconnect", handler);
triggerDisconnect();
expect(handler).toHaveBeenCalledTimes(1);
worker.removeAllListeners();
});
});
// ── Edge cases ──────────────────────────────────────────────────────
describe("edge cases", () => {
it("sendEvent() when process.send is undefined does not throw (graceful fallback)", () => {
const { worker } = createWorker();
// Remove process.send after construction
const savedSend = process.send;
delete (process as any).send;
expect(() => {
worker.sendEvent(TASK_CREATED, { task: {} });
}).not.toThrow();
// Restore
process.send = savedSend;
worker.removeAllListeners();
});
it("sendResponse() sends correctly structured IpcMessage with type, id, and payload", () => {
const { worker, sendFn } = createWorker();
worker.sendResponse(OK, "resp-id-1", { data: { status: "active" } });
expect(sendFn).toHaveBeenCalledTimes(1);
const msg = sendFn.mock.calls[0][0];
expect(msg).toEqual({
type: OK,
id: "resp-id-1",
payload: { data: { status: "active" } },
});
worker.removeAllListeners();
});
});
});

View File

@@ -1,86 +0,0 @@
/**
* Lightweight structured logger for the `@fusion/engine` package.
*
* Usage:
* ```ts
* import { createLogger } from "./logger.js";
* const log = createLogger("my-module");
* log.log("hello"); // → console.error("[my-module] hello")
* log.warn("oops"); // → console.warn("[my-module] oops")
* log.error("fail"); // → console.error("[my-module] fail")
* ```
*
* All engine subsystems should use the pre-built instances exported below
* rather than calling `console.*` directly. This gives us a single point
* of control for filtering, suppressing (e.g. in tests), or redirecting
* engine log output in the future.
*/
export interface Logger {
log(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
/**
* Create a structured logger that prefixes every message with `[prefix]`.
*
* @param prefix - Short subsystem name, e.g. `"scheduler"` or `"executor"`.
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
* engine logs off stdout prevents command/test output consumers from
* receiving Fusion execution chatter.
*
* The logger prepends an internal control-character severity marker
* so dashboard TUI console-capture can preserve info/warn/error
* semantics even when `log()` is transported via `console.error`.
*/
export declare function createLogger(prefix: string): Logger;
/** Logger for the scheduler subsystem. */
export declare const schedulerLog: Logger;
/** Logger for the task executor subsystem. */
export declare const executorLog: Logger;
/** Logger for the triage processor subsystem. */
export declare const triageLog: Logger;
/** Logger for the pi agent session subsystem. */
export declare const piLog: Logger;
/** Logger for extension discovery/provider registration. */
export declare const extensionsLog: Logger;
/** Logger for the merge/auto-merge subsystem. */
export declare const mergerLog: Logger;
/** Logger for the worktree pool subsystem. */
export declare const worktreePoolLog: Logger;
/** Logger for the review subsystem. */
export declare const reviewerLog: Logger;
/** Logger for the PR monitor subsystem. */
export declare const prMonitorLog: Logger;
/** Logger for the project runtime subsystem. */
export declare const runtimeLog: Logger;
/** Logger for the IPC subsystem. */
export declare const ipcLog: Logger;
/** Logger for the project manager subsystem. */
export declare const projectManagerLog: Logger;
/** Logger for the hybrid executor subsystem. */
export declare const hybridExecutorLog: Logger;
/** Logger for the mission autopilot subsystem. */
export declare const autopilotLog: Logger;
/** Logger for the heartbeat execution subsystem. */
export declare const heartbeatLog: Logger;
/** Logger for remote node runtime/client subsystems. */
export declare const remoteNodeLog: Logger;
/** Logger for periodic node health monitor subsystem. */
export declare const nodeHealthMonitorLog: Logger;
/** Logger for the peer exchange (gossip) subsystem. */
export declare const peerExchangeLog: Logger;
/**
* Extract both a short message and a full stack trace from an unknown caught
* value. Use this at catch sites instead of the
* `err instanceof Error ? err.message : String(err)` idiom so that the stack
* is preserved for logs, task `activityLog` entries, and surfaced diagnostics.
*
* `detail` is `message` when no stack is available and `message + "\n" + stack`
* otherwise — suitable for `store.logEntry(taskId, action, detail)`.
*/
export declare function formatError(err: unknown): {
message: string;
stack?: string;
detail: string;
};
//# sourceMappingURL=logger.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,MAAM;IACrB,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;IAChD,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC;CAClD;AASD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAanD;AAED,0CAA0C;AAC1C,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,8CAA8C;AAC9C,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,iDAAiD;AACjD,eAAO,MAAM,KAAK,QAAqB,CAAC;AAExC,4DAA4D;AAC5D,eAAO,MAAM,aAAa,QAA6B,CAAC;AAExD,iDAAiD;AACjD,eAAO,MAAM,SAAS,QAAyB,CAAC;AAEhD,8CAA8C;AAC9C,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D,uCAAuC;AACvC,eAAO,MAAM,WAAW,QAA2B,CAAC;AAEpD,2CAA2C;AAC3C,eAAO,MAAM,YAAY,QAA6B,CAAC;AAEvD,gDAAgD;AAChD,eAAO,MAAM,UAAU,QAA0B,CAAC;AAElD,oCAAoC;AACpC,eAAO,MAAM,MAAM,QAAsB,CAAC;AAE1C,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,gDAAgD;AAChD,eAAO,MAAM,iBAAiB,QAAkC,CAAC;AAEjE,kDAAkD;AAClD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAA4B,CAAC;AAEtD,wDAAwD;AACxD,eAAO,MAAM,aAAa,QAA8B,CAAC;AAEzD,yDAAyD;AACzD,eAAO,MAAM,oBAAoB,QAAsC,CAAC;AAExE,uDAAuD;AACvD,eAAO,MAAM,eAAe,QAAgC,CAAC;AAE7D;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAkB7F"}

View File

@@ -1,115 +0,0 @@
/**
* Lightweight structured logger for the `@fusion/engine` package.
*
* Usage:
* ```ts
* import { createLogger } from "./logger.js";
* const log = createLogger("my-module");
* log.log("hello"); // → console.error("[my-module] hello")
* log.warn("oops"); // → console.warn("[my-module] oops")
* log.error("fail"); // → console.error("[my-module] fail")
* ```
*
* All engine subsystems should use the pre-built instances exported below
* rather than calling `console.*` directly. This gives us a single point
* of control for filtering, suppressing (e.g. in tests), or redirecting
* engine log output in the future.
*/
const LOG_LEVEL_MARKER_PREFIX = "\u0000fnlvl=";
const LOG_LEVEL_MARKER_SUFFIX = "\u0000";
function withSeverityMarker(level, payload) {
return `${LOG_LEVEL_MARKER_PREFIX}${level}${LOG_LEVEL_MARKER_SUFFIX}${payload}`;
}
/**
* Create a structured logger that prefixes every message with `[prefix]`.
*
* @param prefix - Short subsystem name, e.g. `"scheduler"` or `"executor"`.
* @returns A `Logger` whose output is prefixed and sent to stderr. Keeping
* engine logs off stdout prevents command/test output consumers from
* receiving Fusion execution chatter.
*
* The logger prepends an internal control-character severity marker
* so dashboard TUI console-capture can preserve info/warn/error
* semantics even when `log()` is transported via `console.error`.
*/
export function createLogger(prefix) {
const tag = `[${prefix}]`;
return {
log(message, ...args) {
globalThis.console.error(withSeverityMarker("info", `${tag} ${message}`), ...args);
},
warn(message, ...args) {
globalThis.console.warn(withSeverityMarker("warn", `${tag} ${message}`), ...args);
},
error(message, ...args) {
globalThis.console.error(withSeverityMarker("error", `${tag} ${message}`), ...args);
},
};
}
/** Logger for the scheduler subsystem. */
export const schedulerLog = createLogger("scheduler");
/** Logger for the task executor subsystem. */
export const executorLog = createLogger("executor");
/** Logger for the triage processor subsystem. */
export const triageLog = createLogger("triage");
/** Logger for the pi agent session subsystem. */
export const piLog = createLogger("pi");
/** Logger for extension discovery/provider registration. */
export const extensionsLog = createLogger("extensions");
/** Logger for the merge/auto-merge subsystem. */
export const mergerLog = createLogger("merger");
/** Logger for the worktree pool subsystem. */
export const worktreePoolLog = createLogger("worktree-pool");
/** Logger for the review subsystem. */
export const reviewerLog = createLogger("reviewer");
/** Logger for the PR monitor subsystem. */
export const prMonitorLog = createLogger("pr-monitor");
/** Logger for the project runtime subsystem. */
export const runtimeLog = createLogger("runtime");
/** Logger for the IPC subsystem. */
export const ipcLog = createLogger("ipc");
/** Logger for the project manager subsystem. */
export const projectManagerLog = createLogger("project-manager");
/** Logger for the hybrid executor subsystem. */
export const hybridExecutorLog = createLogger("hybrid-executor");
/** Logger for the mission autopilot subsystem. */
export const autopilotLog = createLogger("autopilot");
/** Logger for the heartbeat execution subsystem. */
export const heartbeatLog = createLogger("heartbeat");
/** Logger for remote node runtime/client subsystems. */
export const remoteNodeLog = createLogger("remote-node");
/** Logger for periodic node health monitor subsystem. */
export const nodeHealthMonitorLog = createLogger("node-health-monitor");
/** Logger for the peer exchange (gossip) subsystem. */
export const peerExchangeLog = createLogger("peer-exchange");
/**
* Extract both a short message and a full stack trace from an unknown caught
* value. Use this at catch sites instead of the
* `err instanceof Error ? err.message : String(err)` idiom so that the stack
* is preserved for logs, task `activityLog` entries, and surfaced diagnostics.
*
* `detail` is `message` when no stack is available and `message + "\n" + stack`
* otherwise — suitable for `store.logEntry(taskId, action, detail)`.
*/
export function formatError(err) {
if (err instanceof Error) {
const message = err.message || err.name || "Error";
const stack = err.stack;
const detail = stack && stack.includes(message) ? stack : stack ? `${message}\n${stack}` : message;
return { message, stack, detail };
}
let message;
if (typeof err === "string") {
message = err;
}
else {
try {
message = JSON.stringify(err);
}
catch {
message = String(err);
}
}
return { message, detail: message };
}
//# sourceMappingURL=logger.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,MAAM,uBAAuB,GAAG,cAAc,CAAC;AAC/C,MAAM,uBAAuB,GAAG,QAAQ,CAAC;AAEzC,SAAS,kBAAkB,CAAC,KAAgC,EAAE,OAAe;IAC3E,OAAO,GAAG,uBAAuB,GAAG,KAAK,GAAG,uBAAuB,GAAG,OAAO,EAAE,CAAC;AAClF,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,MAAM,GAAG,GAAG,IAAI,MAAM,GAAG,CAAC;IAC1B,OAAO;QACL,GAAG,CAAC,OAAe,EAAE,GAAG,IAAe;YACrC,OAAO,CAAC,KAAK,CAAC,kBAAkB,CAAC,MAAM,EAAE,GAAG,GAAG,IAAI,OAAO,EAAE,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC;QAC1E,CAAC;QACD,IAAI,CAAC,OAAe,EAAE,GAAG,IAAe;YACtC,OAAO,CAAC,IAAI,CAAC,kBAAkB,CAAC,MAAM,EAAE,GAAG,GAAG,IAAI,OAAO,EAAE,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC;QACzE,CAAC;QACD,KAAK,CAAC,OAAe,EAAE,GAAG,IAAe;YACvC,OAAO,CAAC,KAAK,CAAC,kBAAkB,CAAC,OAAO,EAAE,GAAG,GAAG,IAAI,OAAO,EAAE,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC;QAC3E,CAAC;KACF,CAAC;AACJ,CAAC;AAED,0CAA0C;AAC1C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,iDAAiD;AACjD,MAAM,CAAC,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;AAExC,4DAA4D;AAC5D,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAExD,iDAAiD;AACjD,MAAM,CAAC,MAAM,SAAS,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;AAEhD,8CAA8C;AAC9C,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC;AAE7D,uCAAuC;AACvC,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC;AAEpD,2CAA2C;AAC3C,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;AAEvD,gDAAgD;AAChD,MAAM,CAAC,MAAM,UAAU,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;AAElD,oCAAoC;AACpC,MAAM,CAAC,MAAM,MAAM,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC;AAE1C,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,gDAAgD;AAChD,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC,iBAAiB,CAAC,CAAC;AAEjE,kDAAkD;AAClD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,oDAAoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;AAEtD,wDAAwD;AACxD,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;AAEzD,yDAAyD;AACzD,MAAM,CAAC,MAAM,oBAAoB,GAAG,YAAY,CAAC,qBAAqB,CAAC,CAAC;AAExE,uDAAuD;AACvD,MAAM,CAAC,MAAM,eAAe,GAAG,YAAY,CAAC,eAAe,CAAC,CAAC;AAE7D;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;QACzB,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC;QACnD,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC;QACxB,MAAM,MAAM,GAAG,KAAK,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,OAAO,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;QACnG,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IACpC,CAAC;IACD,IAAI,OAAe,CAAC;IACpB,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,OAAO,GAAG,GAAG,CAAC;IAChB,CAAC;SAAM,CAAC;QACN,IAAI,CAAC;YACH,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AACtC,CAAC"}

View File

@@ -1,95 +0,0 @@
/**
* Shared pi SDK setup for fn engine agents.
*
* Uses Fusion auth for writes and legacy pi auth as a read-only fallback.
* Provides factory functions for creating triage and executor agent sessions.
*/
import { SessionManager, type AgentSession, type ToolDefinition } from "@mariozechner/pi-coding-agent";
import { type SkillSelectionContext } from "./skill-resolver.js";
export interface AgentResult {
session: AgentSession;
/** Path to the persisted session file (undefined for in-memory sessions). */
sessionFile?: string;
}
export interface PromptableSession extends AgentSession {
promptWithFallback: (prompt: string, options?: unknown) => Promise<void>;
}
export declare function promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void>;
/**
* Extract a human-readable model description from an AgentSession.
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
* or `"unknown model"` when the session has no model set.
*/
export declare function describeModel(session: AgentSession): string;
/**
* Default instructions used when calling `session.compact()` for loop recovery.
* These guide the compaction summary to preserve essential context while
* freeing up the context window for continued work.
*/
export declare const COMPACTION_FALLBACK_INSTRUCTIONS: string;
/**
* Compact an agent session's context to free up the context window.
*
* Uses the SDK's native `session.compact()` method when available (the
* preferred path — it produces structured, LLM-generated summaries).
*
* @param session — The agent session to compact
* @param customInstructions — Optional instructions for the compaction summary.
* When not provided, uses COMPACTION_FALLBACK_INSTRUCTIONS.
* @returns The compaction result with summary and token metrics, or null if
* compaction was not available or failed.
*/
export declare function compactSessionContext(session: AgentSession, customInstructions?: string): Promise<{
summary: string;
tokensBefore: number;
} | null>;
export interface AgentOptions {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
customTools?: ToolDefinition[];
onText?: (delta: string) => void;
onThinking?: (delta: string) => void;
onToolStart?: (name: string, args?: Record<string, unknown>) => void;
onToolEnd?: (name: string, isError: boolean, result?: unknown) => void;
/** Default model provider (e.g. "anthropic"). Used with `defaultModelId` to select a specific model. */
defaultProvider?: string;
/** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */
defaultModelId?: string;
/** Optional fallback model provider used when the primary selected model hits
* a retryable provider-side failure such as rate limiting or overload. */
fallbackProvider?: string;
/** Optional fallback model ID used with `fallbackProvider`. */
fallbackModelId?: string;
/** Default thinking effort level (e.g. "medium", "high"). When provided, sets the session's thinking level after creation. */
defaultThinkingLevel?: string;
/** Optional pre-configured SessionManager. When provided, the agent session
* uses this instead of creating an in-memory session. Pass a file-based
* SessionManager to enable session persistence and pause/resume. */
sessionManager?: SessionManager;
/** Optional skill selection context. When provided, the agent session's
* skills are filtered according to project execution settings and any
* caller-requested skill names. Omit to use default skill discovery
* (all discovered skills included). */
skillSelection?: SkillSelectionContext;
/** Convenience: skill names to include in the session. When provided
* (and `skillSelection` is not), auto-constructs a SkillSelectionContext
* from the cwd and these names. Ignored when `skillSelection` is set. */
skills?: string[];
}
/**
* Wrap tools with worktree boundary validation.
* When cwd is a worktree path, file operations are validated against worktree boundaries.
*
* @param tools - Array of tool definitions to wrap
* @param worktreePath - Absolute path to the worktree directory (if applicable)
* @param projectRoot - Absolute path to the project root (if applicable)
* @returns Wrapped tools with boundary validation
*/
export declare function wrapToolsWithBoundary(tools: ToolDefinition[], worktreePath: string | null, projectRoot: string | null): ToolDefinition[];
/**
* Create a pi agent session configured for fn.
* Reuses the user's existing pi auth and model configuration.
*/
export declare function createFnAgent(options: AgentOptions): Promise<AgentResult>;
//# sourceMappingURL=pi.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"pi.d.ts","sourceRoot":"","sources":["pi.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AASH,OAAO,EASL,cAAc,EAEd,KAAK,YAAY,EACjB,KAAK,cAAc,EACpB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,YAAY,CAAC;IACtB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,iBAAkB,SAAQ,YAAY;IACrD,kBAAkB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAkCD,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDhH;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAI3D;AAED;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,QAKlC,CAAC;AA8GZ;;;;;;;;;;;GAWG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,YAAY,EACrB,kBAAkB,CAAC,EAAE,MAAM,GAC1B,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAsB3D;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;IAC9B,WAAW,CAAC,EAAE,cAAc,EAAE,CAAC;IAC/B,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACrE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IACvE,wGAAwG;IACxG,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oGAAoG;IACpG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;+EAC2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8HAA8H;IAC9H,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;yEAEqE;IACrE,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC;;;4CAGwC;IACxC,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;;8EAE0E;IAC1E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AA4QD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,cAAc,EAAE,EACvB,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,WAAW,EAAE,MAAM,GAAG,IAAI,GACzB,cAAc,EAAE,CAmDlB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC,CAoQ/E"}

View File

@@ -1,866 +0,0 @@
/**
* Shared pi SDK setup for fn engine agents.
*
* Uses Fusion auth for writes and legacy pi auth as a read-only fallback.
* Provides factory functions for creating triage and executor agent sessions.
*/
import { existsSync, readFileSync } from "node:fs";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { basename, dirname, join, relative, isAbsolute, resolve } from "node:path";
const execAsync = promisify(exec);
import { createAgentSession, createCodingTools, createExtensionRuntime, createReadOnlyTools, DefaultResourceLoader, DefaultPackageManager, discoverAndLoadExtensions, ModelRegistry, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
import { getEnabledPiExtensionPaths, getFusionAgentDir, getLegacyPiAgentDir, resolvePiExtensionProjectRoot } from "@fusion/core";
import { resolveSessionSkills, createSkillsOverrideFromSelection, } from "./skill-resolver.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
import { piLog, extensionsLog } from "./logger.js";
function getSessionStateError(session) {
const error = session.state?.error;
return typeof error === "string" ? error : "";
}
function clearSessionStateError(session) {
const state = session.state;
if (!state || typeof state !== "object" || !("error" in state)) {
return;
}
try {
state.error = undefined;
}
catch {
// Best effort only. Some session implementations may expose readonly state.
}
}
async function promptSessionAndCheck(session, prompt, options) {
clearSessionStateError(session);
if (options === undefined) {
await session.prompt(prompt);
}
else {
await session.prompt(prompt, options);
}
const stateError = getSessionStateError(session);
if (stateError) {
throw new Error(stateError);
}
}
export async function promptWithFallback(session, prompt, options) {
const maybePromptable = session;
if (typeof maybePromptable.promptWithFallback === "function") {
piLog.log(`promptWithFallback: delegating to session.promptWithFallback (prompt length=${prompt.length})`);
await maybePromptable.promptWithFallback(prompt, options);
piLog.log("promptWithFallback: completed");
return;
}
piLog.log(`promptWithFallback: calling session.prompt (prompt length=${prompt.length})`);
try {
await promptSessionAndCheck(session, prompt, options);
piLog.log("promptWithFallback: prompt completed");
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (!isContextLimitError(errorMessage)) {
piLog.error(`promptWithFallback: non-context error — propagating: ${errorMessage}`);
throw err;
}
// Context limit error — attempt auto-compaction and retry once
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, options);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(session);
const compactResult = await compactSessionContext(session);
if (!compactResult) {
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
throw err;
}
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
await promptSessionAndCheck(session, prompt, options);
piLog.log("promptWithFallback: prompt completed after auto-compaction");
}
catch (retryErr) {
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
throw err; // Throw original error to preserve original context
}
}
}
/**
* Extract a human-readable model description from an AgentSession.
* Returns `"<provider>/<modelId>"` (e.g. `"anthropic/claude-sonnet-4-5"`)
* or `"unknown model"` when the session has no model set.
*/
export function describeModel(session) {
const model = session.model;
if (!model)
return "unknown model";
return `${model.provider}/${model.id}`;
}
/**
* Default instructions used when calling `session.compact()` for loop recovery.
* These guide the compaction summary to preserve essential context while
* freeing up the context window for continued work.
*/
export const COMPACTION_FALLBACK_INSTRUCTIONS = [
"Summarize all completed steps concisely.",
"Preserve the current step number and any in-progress work details.",
"Keep references to key files, decisions, and error states.",
"Discard verbose tool output, repeated attempts, and exploration history.",
].join(" ");
const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000;
function compactMarkdownMemorySection(sectionBody) {
const lines = sectionBody.split("\n");
const kept = [];
let used = 0;
for (const line of lines) {
const trimmed = line.trimEnd();
const normalized = trimmed.trimStart();
const isUseful = normalized.startsWith("##")
|| normalized.startsWith("- ")
|| normalized.startsWith("* ")
|| /^\d+\.\s/.test(normalized)
|| normalized.length === 0;
if (!isUseful) {
continue;
}
const nextLength = used + trimmed.length + 1;
if (nextLength > MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
break;
}
kept.push(trimmed);
used = nextLength;
}
const compacted = kept.join("\n").trim();
if (compacted.length >= sectionBody.trim().length) {
return sectionBody.trim();
}
return [
compacted,
"",
`<!-- Memory compacted from ${sectionBody.length} characters to avoid context overflow. Use memory tools or the selected memory file later only if essential. -->`,
].join("\n").trim();
}
function compactPromptMemory(prompt) {
const sectionPattern = /(^|\n)(## (?:Project Memory|Agent Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
let changed = false;
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix, heading, body) => {
const trimmedBody = body.trim();
if (trimmedBody.length <= MAX_COMPACTED_PROMPT_MEMORY_CHARS) {
return match;
}
const compacted = compactMarkdownMemorySection(trimmedBody);
if (compacted.length >= trimmedBody.length) {
return match;
}
changed = true;
return `${prefix}${heading}${compacted}`;
});
return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null;
}
async function retryWithCompactedPromptMemory(session, prompt, options) {
const compactedPrompt = compactPromptMemory(prompt);
if (!compactedPrompt) {
return { recovered: false };
}
piLog.log(`promptWithFallback: retrying with compacted prompt memory (${prompt.length}${compactedPrompt.length} chars)`);
try {
await promptSessionAndCheck(session, compactedPrompt, options);
piLog.log("promptWithFallback: prompt completed after prompt-memory compaction");
return { recovered: true };
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
piLog.error(`promptWithFallback: retry after prompt-memory compaction failed: ${errorMessage}`);
return { recovered: false, error: err };
}
}
async function flushMemoryBeforeSessionCompaction(session) {
if (session.__fusionMemoryAppendAvailable !== true) {
return;
}
const flushPrompt = [
"Before context compaction, preserve only unresolved durable memory if needed.",
"If memory_append is available and you learned reusable project decisions, conventions, pitfalls, or open loops that are not already saved, append them now.",
"Use layer=\"long-term\" for durable facts and layer=\"daily\" for running notes/open loops.",
"If there is nothing durable to save, reply exactly: NONE.",
].join("\n");
try {
await promptSessionAndCheck(session, flushPrompt);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
piLog.warn(`promptWithFallback: memory flush before compaction skipped: ${errorMessage}`);
}
}
/**
* Compact an agent session's context to free up the context window.
*
* Uses the SDK's native `session.compact()` method when available (the
* preferred path — it produces structured, LLM-generated summaries).
*
* @param session — The agent session to compact
* @param customInstructions — Optional instructions for the compaction summary.
* When not provided, uses COMPACTION_FALLBACK_INSTRUCTIONS.
* @returns The compaction result with summary and token metrics, or null if
* compaction was not available or failed.
*/
export async function compactSessionContext(session, customInstructions) {
const instructions = customInstructions ?? COMPACTION_FALLBACK_INSTRUCTIONS;
// Check if session.compact is available (runtime capability detection)
if (typeof session.compact !== "function") {
return null;
}
try {
const result = await session.compact(instructions);
if (result && typeof result === "object") {
return {
summary: result.summary ?? "",
tokensBefore: result.tokensBefore ?? 0,
};
}
return null;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
piLog.warn(`Context compaction failed (will fall through to kill/requeue): ${msg}`);
return null;
}
}
function resolveConfiguredModel(modelRegistry, kind, provider, modelId) {
if (!provider || !modelId) {
return undefined;
}
const model = modelRegistry.find(provider, modelId);
if (model) {
return model;
}
// Fall back to constructing a model on-the-fly if the provider is known.
// This mirrors the pi CLI's buildFallbackModel behaviour, which accepts any
// model ID for a configured provider (e.g. any OpenRouter model string) even
// when it isn't in the built-in or custom model list.
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider);
if (providerModels.length > 0) {
const baseModel = providerModels[0];
piLog.warn(`${kind} model ${provider}/${modelId} not in registry; using provider base model as template`);
return { ...baseModel, id: modelId, name: modelId };
}
throw new Error(`Configured ${kind} model ${provider}/${modelId} was not found in the pi model registry. ` +
"Open Settings and choose a model from /api/models, or update your pi model configuration.");
}
function isRetryableModelSelectionError(message) {
const normalized = message.toLowerCase();
return normalized.includes("rate limit")
|| normalized.includes("too many requests")
|| normalized.includes("429")
|| normalized.includes("401")
|| normalized.includes("403")
|| normalized.includes("unauthorized")
|| normalized.includes("forbidden")
|| normalized.includes("authentication")
|| normalized.includes("invalid api key")
|| normalized.includes("invalid key")
|| normalized.includes("api key")
|| normalized.includes("overloaded")
|| normalized.includes("quota")
|| normalized.includes("capacity")
|| normalized.includes("temporarily unavailable")
|| normalized.includes("invalid temperature");
}
function readJsonObject(path) {
if (!existsSync(path)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(path, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : {};
}
catch {
return {};
}
}
function normalizeSessionHistoryEntries(sessionManager) {
const entries = sessionManager.fileEntries;
if (!Array.isArray(entries) || entries.length === 0) {
return;
}
let changed = false;
for (const entry of entries) {
if (entry?.type !== "message" || !entry.message || typeof entry.message !== "object") {
continue;
}
const role = entry.message.role;
if (role !== "assistant" && role !== "toolResult") {
continue;
}
if (!("content" in entry.message)) {
entry.message.content = [];
changed = true;
}
}
if (changed) {
sessionManager._rewriteFile?.();
}
}
function normalizeAssistantOrToolResultMessage(message) {
if (!message || typeof message !== "object") {
return false;
}
const role = message.role;
if (role !== "assistant" && role !== "toolResult") {
return false;
}
if (!Array.isArray(message.content)) {
message.content = [];
}
return true;
}
function syncNormalizedMessageIntoAgentState(session, message) {
const messages = session.agent?.state?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return;
}
for (let i = messages.length - 1; i >= 0; i--) {
const candidate = messages[i];
if (!candidate || typeof candidate !== "object") {
continue;
}
if (candidate === message) {
normalizeAssistantOrToolResultMessage(candidate);
return;
}
if (candidate.role !== message.role) {
continue;
}
if (candidate.role === "toolResult") {
if (candidate.toolCallId === message.toolCallId && candidate.toolName === message.toolName) {
normalizeAssistantOrToolResultMessage(candidate);
return;
}
continue;
}
if (candidate.timestamp === message.timestamp) {
normalizeAssistantOrToolResultMessage(candidate);
return;
}
}
}
function installToolResultContentGuard(session) {
if (session.__fusionToolResultGuardInstalled || !session.agent?.afterToolCall) {
return;
}
const originalAfterToolCall = session.agent.afterToolCall.bind(session.agent);
session.agent.afterToolCall = async (payload) => {
const hookResult = await originalAfterToolCall(payload);
if (!hookResult || typeof hookResult !== "object") {
return hookResult;
}
const content = hookResult.content ?? payload.result?.content ?? [];
return {
content: Array.isArray(content) ? content : [],
details: hookResult.details ?? payload.result?.details,
isError: hookResult.isError ?? payload.isError,
};
};
session.__fusionToolResultGuardInstalled = true;
}
function installMessageContentGuard(session, sessionManager) {
if (session.__fusionMessageContentGuardInstalled) {
return;
}
if (typeof session.subscribe === "function") {
session.subscribe((event) => {
if (!event || typeof event !== "object" || event.type !== "message_end") {
return;
}
const message = event.message;
if (!normalizeAssistantOrToolResultMessage(message)) {
return;
}
syncNormalizedMessageIntoAgentState(session, message);
});
}
if (typeof sessionManager.appendMessage === "function") {
const originalAppendMessage = sessionManager.appendMessage.bind(sessionManager);
sessionManager.appendMessage = (message) => {
normalizeAssistantOrToolResultMessage(message);
syncNormalizedMessageIntoAgentState(session, message);
return originalAppendMessage(message);
};
}
session.__fusionMessageContentGuardInstalled = true;
}
function hasPackageManagerSettings(settings) {
return Array.isArray(settings.packages) || Array.isArray(settings.npmCommand);
}
function siblingAgentDir(agentDir, siblingRoot) {
if (basename(agentDir) !== "agent") {
return undefined;
}
return join(dirname(dirname(agentDir)), siblingRoot, "agent");
}
function createReadOnlyPiSettingsView(cwd, agentDir) {
const projectRoot = resolvePiExtensionProjectRoot(cwd);
const fusionAgentDir = agentDir.includes(`${join(".fusion", "agent")}`)
? agentDir
: siblingAgentDir(agentDir, ".fusion");
const legacyAgentDir = agentDir.includes(`${join(".pi", "agent")}`)
? agentDir
: siblingAgentDir(agentDir, ".pi");
const legacyGlobalSettings = legacyAgentDir ? readJsonObject(join(legacyAgentDir, "settings.json")) : {};
const fusionGlobalSettings = fusionAgentDir ? readJsonObject(join(fusionAgentDir, "settings.json")) : {};
const directGlobalSettings = readJsonObject(join(agentDir, "settings.json"));
const globalSettings = { ...legacyGlobalSettings, ...directGlobalSettings, ...fusionGlobalSettings };
const fusionProjectSettings = readJsonObject(join(projectRoot, ".fusion", "settings.json"));
const mergedSettings = { ...globalSettings, ...fusionProjectSettings };
return {
getGlobalSettings: () => globalThis.structuredClone(globalSettings),
getProjectSettings: () => globalThis.structuredClone(fusionProjectSettings),
getNpmCommand: () => Array.isArray(mergedSettings.npmCommand)
? [...mergedSettings.npmCommand]
: undefined,
};
}
function getPackageManagerAgentDir() {
const fusionAgentDir = getFusionAgentDir();
const legacyAgentDir = getLegacyPiAgentDir();
const fusionSettings = readJsonObject(join(fusionAgentDir, "settings.json"));
const legacySettings = readJsonObject(join(legacyAgentDir, "settings.json"));
if (hasPackageManagerSettings(fusionSettings) || !existsSync(legacyAgentDir)) {
return fusionAgentDir;
}
if (hasPackageManagerSettings(legacySettings)) {
return legacyAgentDir;
}
return existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir;
}
async function registerExtensionProviders(cwd, modelRegistry) {
try {
const agentDir = getPackageManagerAgentDir();
const packageManager = new DefaultPackageManager({
cwd,
agentDir,
settingsManager: createReadOnlyPiSettingsView(cwd, agentDir),
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
.filter((resource) => resource.enabled)
.map((resource) => resource.path);
const extensionsResult = await discoverAndLoadExtensions([...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths], cwd, join(resolvePiExtensionProjectRoot(cwd), ".fusion", "disabled-auto-extension-discovery"));
for (const { path, error } of extensionsResult.errors) {
extensionsLog.warn(`Failed to load ${path}: ${error}`);
}
for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) {
try {
modelRegistry.registerProvider(name, config);
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
extensionsLog.warn(`Failed to register provider from ${extensionPath}: ${message}`);
}
}
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
extensionsLog.error(`Failed to discover extensions: ${message}`);
createExtensionRuntime();
modelRegistry.refresh();
}
}
// ── Worktree Path Boundary Helpers ──────────────────────────────────────────
/**
* Detect if a path is a task worktree under `.worktrees/`.
* Returns the project root if the path is a worktree, otherwise null.
*
* Examples:
* `/project/.worktrees/fn-001` → `/project`
* `/project/.worktrees/fn-001/src/file.ts` → `/project`
* `/project` → null (not a worktree)
*/
function getProjectRootFromWorktree(cwd) {
// Match paths like /project/.worktrees/task-id or /project/.worktrees/task-id/...
const match = cwd.match(/^(.+?)\/\.worktrees\/[^/]+/);
if (match) {
return match[1];
}
return null;
}
async function isRegisteredGitWorktree(projectRoot, worktreePath) {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: projectRoot,
encoding: "utf-8",
});
const resolvedWorktree = resolve(worktreePath);
return stdout.split("\n").some((line) => line.startsWith("worktree ") && resolve(line.slice("worktree ".length)) === resolvedWorktree);
}
catch {
return false;
}
}
async function isCompleteGitWorktree(worktreePath) {
try {
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
cwd: worktreePath,
encoding: "utf-8",
});
return resolve(stdout.trim()) === resolve(worktreePath);
}
catch {
return false;
}
}
async function assertValidWorktreeSession(cwd, projectRoot) {
if (!existsSync(cwd)) {
throw new Error(`Refusing to start coding agent in missing worktree: ${cwd}`);
}
if (!existsSync(join(cwd, ".git")) || !await isCompleteGitWorktree(cwd)) {
throw new Error(`Refusing to start coding agent in incomplete worktree: ${cwd}`);
}
if (!await isRegisteredGitWorktree(projectRoot, cwd)) {
throw new Error(`Refusing to start coding agent in unregistered git worktree: ${cwd}`);
}
}
/**
* Check if a path is allowed to be accessed from a worktree session.
* Rules:
* - Paths inside the worktree are always allowed
* - Project root .fusion/memory/ files are allowed (for durable project learnings)
* - Task attachments under .fusion/tasks/N/attachments/ are allowed (for reading context files)
* - All other paths outside the worktree are rejected
*
* @param worktreePath - Absolute path to the worktree directory
* @param projectRoot - Absolute path to the project root (derived from worktree)
* @param requestedPath - The path being accessed
* @returns true if allowed, false if rejected
*/
function isWorktreeAllowedPath(worktreePath, projectRoot, requestedPath) {
// Normalize paths
const worktreeResolved = resolve(worktreePath);
const projectRootResolved = resolve(projectRoot);
const requestedResolved = isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(worktreeResolved, requestedPath);
// Check if path is inside the worktree
const relToWorktree = relative(worktreeResolved, requestedResolved);
if (!relToWorktree.startsWith("..") && !isAbsolute(relToWorktree)) {
return true; // Path is inside the worktree
}
// Exception: project root `.fusion/memory/` files for durable project learnings
const relToProjectRoot = relative(projectRootResolved, requestedResolved).replace(/\\/g, "/");
if (relToProjectRoot === ".fusion/memory" ||
relToProjectRoot === ".fusion/memory/" ||
relToProjectRoot.startsWith(".fusion/memory/")) {
return true;
}
// Exception: task attachments under `.fusion/tasks/*/attachments/*`
if (relToProjectRoot.match(/^\.fusion\/tasks\/[^/]+\/attachments\//)) {
return true;
}
// All other paths outside the worktree are rejected
return false;
}
/**
* Wrap tools with worktree boundary validation.
* When cwd is a worktree path, file operations are validated against worktree boundaries.
*
* @param tools - Array of tool definitions to wrap
* @param worktreePath - Absolute path to the worktree directory (if applicable)
* @param projectRoot - Absolute path to the project root (if applicable)
* @returns Wrapped tools with boundary validation
*/
export function wrapToolsWithBoundary(tools, worktreePath, projectRoot) {
if (!worktreePath || !projectRoot) {
return tools; // Not a worktree session, no wrapping needed
}
return tools.map((tool) => {
// Only wrap tools that access the filesystem
const fileToolNames = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
if (!fileToolNames.has(tool.name)) {
return tool;
}
// Store the original execute function
const originalExecute = tool.execute;
return {
...tool,
execute: async (...args) => {
const params = args[1];
// Check path argument for file operations
const pathArg = params.path;
if (pathArg && !isWorktreeAllowedPath(worktreePath, projectRoot, pathArg)) {
const relToProject = relative(projectRoot, pathArg);
return {
ok: false,
error: `Path "${relToProject}" is outside the worktree boundary. ` +
`Coding agents can only modify files inside the current worktree. ` +
`Exception: .fusion/memory/ (project root) and .fusion/tasks/*/attachments/* are permitted for reading.`,
};
}
// For bash, also check the working directory if specified
const cwdArg = params.cwd;
if (tool.name === "bash" && cwdArg && !isWorktreeAllowedPath(worktreePath, projectRoot, cwdArg)) {
return {
ok: false,
error: `Working directory is outside the worktree boundary. ` +
`Commands must run inside the worktree.`,
};
}
// Call the original tool implementation with all arguments passed through
return originalExecute(...args);
},
};
});
}
/**
* Create a pi agent session configured for fn.
* Reuses the user's existing pi auth and model configuration.
*/
export async function createFnAgent(options) {
piLog.log(`createFnAgent called (cwd=${options.cwd}, tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
const authStorage = createFusionAuthStorage();
const modelRegistry = new ModelRegistry(authStorage, getModelRegistryModelsPath());
await registerExtensionProviders(options.cwd, modelRegistry);
const tools = options.tools === "readonly"
? createReadOnlyTools(options.cwd)
: createCodingTools(options.cwd);
// Detect if this is a worktree session and apply path boundaries
const worktreePath = options.cwd;
const projectRoot = getProjectRootFromWorktree(worktreePath);
if (projectRoot) {
await assertValidWorktreeSession(worktreePath, projectRoot);
}
const wrappedTools = wrapToolsWithBoundary(tools, worktreePath, projectRoot);
// Compaction is explicitly enabled to prevent context-window overflow during
// long-running agent conversations (triage, execution, review, merge).
// When the context fills up, pi auto-compacts the conversation history to
// keep the session alive without manual intervention. This must remain enabled
// as a reliability safeguard — disabling it would cause overflow failures.
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: true },
retry: { enabled: true, maxRetries: 3 },
});
// Resolve explicit model selection if provider and model ID are specified
const selectedModel = resolveConfiguredModel(modelRegistry, "primary", options.defaultProvider, options.defaultModelId);
const fallbackModel = resolveConfiguredModel(modelRegistry, "fallback", options.fallbackProvider, options.fallbackModelId);
// Resolve skill selection: explicit skillSelection wins over convenience `skills`
let effectiveSkillSelection = options.skillSelection;
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {
piLog.log(`Using skills from convenience parameter: [${options.skills.join(", ")}]`);
effectiveSkillSelection = {
projectRootDir: options.cwd,
requestedSkillNames: options.skills,
sessionPurpose: "executor",
};
}
// Resolve skill selection if provided
let skillsOverrideFn;
if (effectiveSkillSelection) {
const selectionResult = resolveSessionSkills(effectiveSkillSelection);
if (selectionResult.diagnostics.length > 0) {
const purpose = effectiveSkillSelection.sessionPurpose ?? "skills";
for (const diag of selectionResult.diagnostics) {
piLog.warn(`[skills] [${purpose}] ${diag.type}: ${diag.message}`);
}
}
skillsOverrideFn = createSkillsOverrideFromSelection(selectionResult, {
requestedSkillNames: effectiveSkillSelection.requestedSkillNames,
sessionPurpose: effectiveSkillSelection.sessionPurpose,
});
}
const resourceLoader = new DefaultResourceLoader({
cwd: options.cwd,
settingsManager,
systemPromptOverride: () => options.systemPrompt,
appendSystemPromptOverride: () => [],
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
});
await resourceLoader.reload();
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
normalizeSessionHistoryEntries(sessionManager);
const createSessionWithModel = async (modelOverride) => {
return createAgentSession({
cwd: options.cwd,
authStorage,
modelRegistry,
resourceLoader,
tools: wrappedTools,
customTools: options.customTools,
sessionManager,
settingsManager,
...(modelOverride ? { model: modelOverride } : {}),
});
};
let sessionResult;
let usingFallback = false;
try {
sessionResult = await createSessionWithModel(selectedModel);
piLog.log(`Session created successfully (model=${selectedModel ? `${selectedModel.provider}/${selectedModel.id}` : "default"})`);
}
catch (err) {
if (!fallbackModel || !selectedModel || !isRetryableModelSelectionError(err?.message || "")) {
piLog.error(`Session creation failed: ${err.message}`);
throw err;
}
piLog.warn(`Primary model failed (${err.message}), trying fallback`);
usingFallback = true;
sessionResult = await createSessionWithModel(fallbackModel);
piLog.log("Fallback session created successfully");
}
const { session } = sessionResult;
installToolResultContentGuard(session);
installMessageContentGuard(session, sessionManager);
session.__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
const promptableSession = session;
promptableSession.promptWithFallback = async (prompt, promptOptions) => {
try {
await promptSessionAndCheck(session, prompt, promptOptions);
return;
}
catch (err) {
const errorMessage = err?.message || "";
if (isContextLimitError(errorMessage)) {
// Context limit error — attempt auto-compaction and retry once
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(session);
const compactResult = await compactSessionContext(session);
if (compactResult) {
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
await promptSessionAndCheck(session, prompt, promptOptions);
return;
}
catch (retryErr) {
const retryErrorMessage = retryErr?.message || "";
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
// Throw original error to preserve original context
throw err;
}
}
else {
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
throw err;
}
}
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
throw err;
}
usingFallback = true;
try {
session.dispose();
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
piLog.warn(`Failed to dispose session during model fallback swap: ${msg}`);
}
const fallbackSessionResult = await createSessionWithModel(fallbackModel);
const fallbackSession = fallbackSessionResult.session;
installToolResultContentGuard(fallbackSession);
installMessageContentGuard(fallbackSession, sessionManager);
fallbackSession.__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === "memory_append") === true;
if (options.defaultThinkingLevel) {
fallbackSession.setThinkingLevel(options.defaultThinkingLevel);
}
fallbackSession.subscribe((event) => {
if (event.type === "message_update") {
const msgEvent = event.assistantMessageEvent;
if (msgEvent.type === "text_delta") {
options.onText?.(msgEvent.delta);
}
else if (msgEvent.type === "thinking_delta") {
options.onThinking?.(msgEvent.delta);
}
}
if (event.type === "tool_execution_start") {
options.onToolStart?.(event.toolName, event.args);
}
if (event.type === "tool_execution_end") {
options.onToolEnd?.(event.toolName, event.isError, event.result);
}
});
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(fallbackSession));
Object.assign(promptableSession, fallbackSession);
promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback;
// Retry with fallback model, also with auto-compaction support
try {
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
return;
}
catch (fallbackErr) {
const fallbackErrorMessage = fallbackErr?.message || "";
if (isContextLimitError(fallbackErrorMessage)) {
const promptMemoryRetry = await retryWithCompactedPromptMemory(fallbackSession, prompt, promptOptions);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: fallback session context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(fallbackSession);
const compactResult = await compactSessionContext(fallbackSession);
if (compactResult) {
piLog.log(`promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`);
try {
await promptSessionAndCheck(fallbackSession, prompt, promptOptions);
return;
}
catch (retryErr) {
const retryErrorMessage = retryErr?.message || "";
piLog.error(`promptWithFallback: fallback retry after auto-compaction failed: ${retryErrorMessage}`);
throw fallbackErr; // Throw original fallback error
}
}
else {
piLog.error("promptWithFallback: fallback compaction unavailable — propagating original error");
throw fallbackErr;
}
}
throw fallbackErr;
}
}
};
// Apply thinking level if specified
if (options.defaultThinkingLevel) {
promptableSession.setThinkingLevel(options.defaultThinkingLevel);
}
// Wire up event listeners
promptableSession.subscribe((event) => {
if (event.type === "message_update") {
const msgEvent = event.assistantMessageEvent;
if (msgEvent.type === "text_delta") {
options.onText?.(msgEvent.delta);
}
else if (msgEvent.type === "thinking_delta") {
options.onThinking?.(msgEvent.delta);
}
}
if (event.type === "tool_execution_start") {
options.onToolStart?.(event.toolName, event.args);
}
if (event.type === "tool_execution_end") {
options.onToolEnd?.(event.toolName, event.isError, event.result);
}
});
return { session: promptableSession, sessionFile: promptableSession.sessionFile };
}
//# sourceMappingURL=pi.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,715 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { CentralCore, Task } from "@fusion/core";
import { ChildProcessRuntime } from "./child-process-runtime.js";
import type {
ProjectRuntimeConfig,
RuntimeMetrics,
RuntimeStatus,
} from "../project-runtime.js";
import { runtimeLog } from "../logger.js";
import {
START_RUNTIME,
STOP_RUNTIME,
GET_METRICS,
TASK_CREATED,
TASK_MOVED,
TASK_UPDATED,
ERROR_EVENT,
HEALTH_CHANGED,
OK,
ERROR,
PONG,
} from "../ipc/ipc-protocol.js";
type Listener = (...args: any[]) => void;
type CommandMessage = {
type: string;
id: string;
payload: unknown;
};
type MockChildOptions = {
pingResults?: boolean[];
metricsResponse?: RuntimeMetrics;
sendCallbackErrors?: Partial<Record<string, Error>>;
markKilledOnSigterm?: boolean;
emitExitOnKill?: boolean;
};
type MockChildProcess = {
on: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
kill: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
emit: (event: string, ...args: unknown[]) => void;
connected: boolean;
killed: boolean;
sentMessages: CommandMessage[];
};
const forkedChildren: MockChildProcess[] = [];
const queuedForkOptions: MockChildOptions[] = [];
function createMockChildProcess(options: MockChildOptions = {}): MockChildProcess {
const listeners = new Map<string, Listener[]>();
const pingResults = [...(options.pingResults ?? [])];
const child: MockChildProcess = {
on: vi.fn((event: string, handler: Listener) => {
const existing = listeners.get(event) ?? [];
existing.push(handler);
listeners.set(event, existing);
return child;
}),
send: vi.fn((message: CommandMessage, callback?: (error: Error | null) => void) => {
child.sentMessages.push(message);
const sendError = options.sendCallbackErrors?.[message.type];
if (sendError) {
callback?.(sendError);
return false;
}
callback?.(null);
const respond = (type: string, payload: unknown) => {
Promise.resolve().then(() => {
child.emit("message", {
type,
id: message.id,
payload,
});
});
};
if (message.type === START_RUNTIME) {
respond(OK, { data: { status: "active" } });
} else if (message.type === STOP_RUNTIME) {
respond(OK, { data: { stopped: true } });
} else if (message.type === GET_METRICS) {
respond(OK, {
data:
options.metricsResponse ??
{
inFlightTasks: 4,
activeAgents: 2,
lastActivityAt: "2026-04-08T00:00:00.000Z",
},
});
} else if (message.type === "PING") {
const pingOk = pingResults.shift() ?? true;
if (pingOk) {
respond(PONG, { timestamp: "2026-04-08T00:00:00.000Z" });
} else {
respond(ERROR, { message: "Ping failed", code: "PING_FAILED" });
}
}
return true;
}),
kill: vi.fn((signal?: string | number) => {
if (signal === "SIGKILL" || (signal === "SIGTERM" && options.markKilledOnSigterm !== false)) {
child.killed = true;
}
if (options.emitExitOnKill) {
child.emit("exit", signal === "SIGKILL" ? 137 : 0, typeof signal === "string" ? signal : null);
}
return true;
}),
disconnect: vi.fn(() => {
child.connected = false;
child.emit("disconnect");
}),
emit: (event: string, ...args: unknown[]) => {
for (const handler of listeners.get(event) ?? []) {
handler(...(args as any[]));
}
},
connected: true,
killed: false,
sentMessages: [],
};
return child;
}
const mockFork = vi.fn(() => {
const options = queuedForkOptions.shift() ?? {};
const child = createMockChildProcess(options);
forkedChildren.push(child);
return child;
});
vi.mock("node:child_process", () => ({
fork: (...args: unknown[]) => (mockFork as (...mockArgs: unknown[]) => unknown)(...args),
}));
function queueChild(options: MockChildOptions = {}): void {
queuedForkOptions.push(options);
}
function getLatestChild(): MockChildProcess {
const child = forkedChildren.at(-1);
if (!child) {
throw new Error("Expected a forked child process");
}
return child;
}
function getMessages(child: MockChildProcess, type: string): CommandMessage[] {
return child.sentMessages.filter((message) => message.type === type);
}
function createMockTask(id: string): Task {
return {
id,
title: `${id} title`,
description: `${id} description`,
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
size: "M",
reviewLevel: 1,
log: [],
attachments: [],
} as Task;
}
describe("ChildProcessRuntime", () => {
let runtime: ChildProcessRuntime;
let runtimeAny: any;
const testConfig: ProjectRuntimeConfig = {
projectId: "proj_test123",
workingDirectory: "/tmp/test-project",
isolationMode: "child-process",
maxConcurrent: 2,
maxWorktrees: 4,
};
beforeEach(() => {
mockFork.mockClear();
forkedChildren.length = 0;
queuedForkOptions.length = 0;
const mockCentralCore = {
getGlobalConcurrencyState: vi.fn().mockResolvedValue({
globalMaxConcurrent: 4,
currentlyActive: 0,
queuedCount: 0,
projectsActive: {},
}),
} as unknown as CentralCore;
runtime = new ChildProcessRuntime(testConfig, mockCentralCore);
runtimeAny = runtime as any;
});
afterEach(async () => {
try {
await runtime.stop();
} catch {
// Ignore cleanup failures
}
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("startup sequence", () => {
it("transitions stopped → starting → active, forks worker path, and sends START_RUNTIME config", async () => {
queueChild();
const transitions: RuntimeStatus[] = [];
runtime.on("health-changed", (data) => transitions.push(data.status));
await runtime.start();
const child = getLatestChild();
expect(transitions).toEqual(["starting", "active"]);
expect(runtime.getStatus()).toBe("active");
expect(mockFork).toHaveBeenCalledWith(
expect.stringMatching(/child-process-worker\.(ts|js)$/),
[],
expect.objectContaining({
silent: true,
execArgv: [],
})
);
const startMessages = getMessages(child, START_RUNTIME);
expect(startMessages).toHaveLength(1);
expect(startMessages[0]?.payload).toEqual({ config: testConfig });
});
it("sets status to errored and emits error when startup fails", async () => {
queueChild({
sendCallbackErrors: {
[START_RUNTIME]: new Error("start send failed"),
},
});
const errorSpy = vi.fn();
runtime.on("error", errorSpy);
await expect(runtime.start()).rejects.toThrow("Failed to send command: start send failed");
expect(runtime.getStatus()).toBe("errored");
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error);
});
it("throws when start() is called in non-stopped states", async () => {
const blockedStates: RuntimeStatus[] = ["starting", "active", "stopping"];
for (const status of blockedStates) {
runtimeAny.status = status;
await expect(runtime.start()).rejects.toThrow(`Cannot start runtime: current status is ${status}`);
}
});
});
describe("shutdown sequence", () => {
it("transitions active → stopping → stopped and sends STOP_RUNTIME with timeout", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const transitions: RuntimeStatus[] = [];
runtime.on("health-changed", (data) => transitions.push(data.status));
await runtime.stop();
expect(transitions).toEqual(["stopping", "stopped"]);
expect(runtime.getStatus()).toBe("stopped");
expect(getMessages(child, STOP_RUNTIME)).toHaveLength(1);
expect(getMessages(child, STOP_RUNTIME)[0]?.payload).toEqual({ timeoutMs: 30000 });
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
});
it("is idempotent and does not send duplicate STOP_RUNTIME commands", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
await runtime.stop();
await runtime.stop();
expect(getMessages(child, STOP_RUNTIME)).toHaveLength(1);
expect(child.kill).toHaveBeenCalledTimes(1);
});
it("returns without error when stop() is called while already stopped", async () => {
await expect(runtime.stop()).resolves.toBeUndefined();
expect(runtime.getStatus()).toBe("stopped");
});
it("handles stop() gracefully when IPC is already disconnected", async () => {
queueChild();
runtime.on("error", () => {
// swallow asynchronous error events from disconnection path
});
await runtime.start();
const child = getLatestChild();
child.connected = false;
child.emit("disconnect");
await expect(runtime.stop()).resolves.toBeUndefined();
expect(runtime.getStatus()).toBe("stopped");
});
it("force-kills with SIGKILL after 5s timeout when child remains alive", async () => {
vi.useFakeTimers();
queueChild({ markKilledOnSigterm: false });
await runtime.start();
const child = getLatestChild();
await runtime.stop();
// Keep a live child reference so the delayed SIGKILL callback can execute the force-kill path.
runtimeAny.child = child;
child.killed = false;
await vi.advanceTimersByTimeAsync(5000);
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
});
});
describe("health monitoring and restart", () => {
it("starts health monitoring after start() and performs periodic pings", async () => {
vi.useFakeTimers();
queueChild({ pingResults: [true, true] });
await runtime.start();
const child = getLatestChild();
expect(getMessages(child, "PING")).toHaveLength(0);
await vi.advanceTimersByTimeAsync(5000);
expect(getMessages(child, "PING")).toHaveLength(1);
});
it("resets missed heartbeat count to 0 after a successful ping", async () => {
vi.useFakeTimers();
queueChild({ pingResults: [false, true] });
runtime.on("error", () => {
// swallow
});
await runtime.start();
await vi.advanceTimersByTimeAsync(5000);
expect(runtimeAny.healthMonitor.getMissedHeartbeats()).toBe(1);
await vi.advanceTimersByTimeAsync(5000);
expect(runtimeAny.healthMonitor.getMissedHeartbeats()).toBe(0);
});
it("triggers handleUnhealthy after three missed heartbeats", async () => {
vi.useFakeTimers();
queueChild({ pingResults: [false, false, false] });
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
await runtime.start();
await vi.advanceTimersByTimeAsync(15000);
expect(unhealthySpy).toHaveBeenCalledTimes(1);
});
it("uses exponential restart delays: 1000ms, 5000ms, 15000ms", () => {
vi.useFakeTimers();
runtimeAny.status = "active";
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
runtimeAny.handleUnhealthy();
runtimeAny.handleUnhealthy();
runtimeAny.handleUnhealthy();
const delays = timeoutSpy.mock.calls.map((call) => Number(call[1]));
expect(delays.slice(0, 3)).toEqual([1000, 5000, 15000]);
});
it("transitions to errored and emits error after max restart attempts", () => {
runtimeAny.status = "active";
const errorSpy = vi.fn();
runtime.on("error", errorSpy);
runtimeAny.handleUnhealthy();
runtimeAny.handleUnhealthy();
runtimeAny.handleUnhealthy();
runtimeAny.handleUnhealthy();
expect(runtime.getStatus()).toBe("errored");
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect((errorSpy.mock.calls[0]?.[0] as Error).message).toContain("max restart attempts");
});
it("resets restart attempt counter after a successful health check", async () => {
vi.useFakeTimers();
queueChild({ pingResults: [true] });
await runtime.start();
runtimeAny.healthMonitor.incrementRestartAttempts();
runtimeAny.healthMonitor.incrementRestartAttempts();
expect(runtimeAny.healthMonitor.getRestartAttempts()).toBe(2);
await vi.advanceTimersByTimeAsync(5000);
expect(runtimeAny.healthMonitor.getRestartAttempts()).toBe(0);
});
it("stops health checks after stop()", async () => {
vi.useFakeTimers();
queueChild({ pingResults: [true, true, true] });
await runtime.start();
const child = getLatestChild();
await vi.advanceTimersByTimeAsync(5000);
const pingCountBeforeStop = getMessages(child, "PING").length;
await runtime.stop();
await vi.advanceTimersByTimeAsync(20000);
expect(getMessages(child, "PING").length).toBe(pingCountBeforeStop);
});
});
describe("child process exit and disconnect", () => {
it("unexpected child exit while active triggers restart handling", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
child.emit("exit", 1, null);
expect(unhealthySpy).toHaveBeenCalled();
});
it("child exit while stopping does not trigger restart", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
runtimeAny.status = "stopping";
child.emit("exit", 1, null);
expect(unhealthySpy).not.toHaveBeenCalled();
});
it("child exit while stopped does not trigger restart", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
runtimeAny.status = "stopped";
child.emit("exit", 1, null);
expect(unhealthySpy).not.toHaveBeenCalled();
});
it("IPC disconnect while active triggers restart handling", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
child.emit("disconnect");
expect(unhealthySpy).toHaveBeenCalled();
});
it("IPC disconnect while stopping does not trigger restart", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const unhealthySpy = vi.spyOn(runtimeAny, "handleUnhealthy").mockImplementation(() => {});
runtimeAny.status = "stopping";
child.emit("disconnect");
expect(unhealthySpy).not.toHaveBeenCalled();
});
});
describe("event forwarding", () => {
it("forwards TASK_CREATED as task:created", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const task = createMockTask("FN-1279-A");
const createdSpy = vi.fn();
runtime.on("task:created", createdSpy);
child.emit("message", {
type: TASK_CREATED,
id: "evt-created",
payload: { task },
});
expect(createdSpy).toHaveBeenCalledWith(task);
});
it("forwards TASK_MOVED as task:moved with { task, from, to } shape", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const task = createMockTask("FN-1279-B");
const movedSpy = vi.fn();
runtime.on("task:moved", movedSpy);
child.emit("message", {
type: TASK_MOVED,
id: "evt-moved",
payload: { task, from: "todo", to: "in-progress" },
});
expect(movedSpy).toHaveBeenCalledWith({ task, from: "todo", to: "in-progress" });
});
it("forwards TASK_UPDATED as task:updated", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const task = createMockTask("FN-1279-C");
const updatedSpy = vi.fn();
runtime.on("task:updated", updatedSpy);
child.emit("message", {
type: TASK_UPDATED,
id: "evt-updated",
payload: { task },
});
expect(updatedSpy).toHaveBeenCalledWith(task);
});
it("forwards ERROR_EVENT as Error instance and preserves error code", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const errorSpy = vi.fn();
runtime.on("error", errorSpy);
child.emit("message", {
type: ERROR_EVENT,
id: "evt-error",
payload: { message: "worker failed", code: "WORKER_FAILURE" },
});
expect(errorSpy).toHaveBeenCalledTimes(1);
const forwardedError = errorSpy.mock.calls[0]?.[0] as Error & { code?: string };
expect(forwardedError).toBeInstanceOf(Error);
expect(forwardedError.message).toBe("worker failed");
expect(forwardedError.code).toBe("WORKER_FAILURE");
});
it("applies HEALTH_CHANGED payload to status and emits health-changed", async () => {
queueChild();
await runtime.start();
const child = getLatestChild();
const healthSpy = vi.fn();
runtime.on("health-changed", healthSpy);
healthSpy.mockClear();
child.emit("message", {
type: HEALTH_CHANGED,
id: "evt-health",
payload: { status: "paused", previous: "active" },
});
expect(runtime.getStatus()).toBe("paused");
expect(healthSpy).toHaveBeenCalledWith({ status: "paused", previous: "active" });
});
});
describe("metrics and inaccessible accessors", () => {
it("returns cached metrics when IPC is disconnected", () => {
runtimeAny.lastMetrics = {
inFlightTasks: 9,
activeAgents: 3,
lastActivityAt: "2026-04-08T01:00:00.000Z",
};
const metrics = runtime.getMetrics();
expect(metrics.inFlightTasks).toBe(9);
expect(metrics.activeAgents).toBe(3);
expect(typeof metrics.lastActivityAt).toBe("string");
});
it("updates cached metrics when GET_METRICS response is received", async () => {
queueChild({
metricsResponse: {
inFlightTasks: 12,
activeAgents: 5,
lastActivityAt: "2026-04-08T02:00:00.000Z",
},
});
await runtime.start();
runtime.getMetrics();
await vi.waitFor(() => {
expect(runtimeAny.lastMetrics).toEqual({
inFlightTasks: 12,
activeAgents: 5,
lastActivityAt: "2026-04-08T02:00:00.000Z",
});
});
});
it("ignores GET_METRICS IPC errors and returns the last known metrics", async () => {
queueChild({
sendCallbackErrors: {
[GET_METRICS]: new Error("metrics unavailable"),
},
});
await runtime.start();
runtimeAny.lastMetrics = {
inFlightTasks: 21,
activeAgents: 8,
lastActivityAt: "2026-04-08T03:00:00.000Z",
};
const metrics = runtime.getMetrics();
expect(metrics.inFlightTasks).toBe(21);
expect(metrics.activeAgents).toBe(8);
await Promise.resolve();
expect(runtimeAny.lastMetrics).toEqual({
inFlightTasks: 21,
activeAgents: 8,
lastActivityAt: "2026-04-08T03:00:00.000Z",
});
});
it("logs warning when GET_METRICS IPC query fails", async () => {
const warnSpy = vi.spyOn(runtimeLog, "warn").mockImplementation(() => {});
queueChild({
sendCallbackErrors: {
[GET_METRICS]: new Error("metrics unavailable"),
},
});
await runtime.start();
runtimeAny.lastMetrics = {
inFlightTasks: 1,
activeAgents: 0,
lastActivityAt: "2026-04-08T04:00:00.000Z",
};
const metrics = runtime.getMetrics();
expect(metrics.inFlightTasks).toBe(1);
expect(metrics.activeAgents).toBe(0);
await vi.waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("GET_METRICS IPC query failed, using cached value"),
);
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("metrics unavailable"));
warnSpy.mockRestore();
});
it("getTaskStore() always throws not accessible error", () => {
expect(() => runtime.getTaskStore()).toThrow("not accessible in ChildProcessRuntime");
});
it("getScheduler() always throws not accessible error", () => {
expect(() => runtime.getScheduler()).toThrow("not accessible in ChildProcessRuntime");
});
});
});

View File

@@ -1,390 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { RuntimeMetrics, RuntimeStatus, ProjectRuntimeConfig } from "../project-runtime.js";
import {
START_RUNTIME,
STOP_RUNTIME,
GET_STATUS,
GET_METRICS,
ERROR_EVENT,
} from "../ipc/ipc-protocol.js";
const mockState = vi.hoisted(() => ({
ipcWorkers: [] as any[],
runtimes: [] as any[],
}));
vi.mock("../logger.js", () => {
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
return {
runtimeLog: mockLogger,
createLogger: () => mockLogger,
schedulerLog: mockLogger,
triageLog: mockLogger,
};
});
vi.mock("@fusion/core", () => ({
CentralCore: class MockCentralCore {},
}));
vi.mock("../ipc/ipc-worker.js", () => {
class MockIpcWorker {
handlers = new Map<string, (payload: unknown) => Promise<unknown> | unknown>();
onCommand = vi.fn((type: string, handler: (payload: unknown) => Promise<unknown> | unknown) => {
this.handlers.set(type, handler);
});
sendEvent = vi.fn();
shutdown = vi.fn();
constructor() {
mockState.ipcWorkers.push(this);
}
}
return { IpcWorker: MockIpcWorker };
});
vi.mock("./in-process-runtime.js", () => {
class MockInProcessRuntime {
status: RuntimeStatus = "stopped";
metrics: RuntimeMetrics = {
inFlightTasks: 1,
activeAgents: 1,
lastActivityAt: "2026-04-08T00:00:00.000Z",
};
listeners = new Map<string, Array<(...args: any[]) => void>>();
start = vi.fn(async () => {
this.status = "active";
});
stop = vi.fn(async () => {
this.status = "stopped";
});
getStatus = vi.fn(() => this.status);
getMetrics = vi.fn(() => this.metrics);
on = vi.fn((event: string, handler: (...args: any[]) => void) => {
const existing = this.listeners.get(event) ?? [];
existing.push(handler);
this.listeners.set(event, existing);
return this;
});
emit(event: string, ...args: any[]) {
for (const handler of this.listeners.get(event) ?? []) {
handler(...args);
}
}
constructor(
public config: ProjectRuntimeConfig,
public centralCore: unknown
) {
mockState.runtimes.push(this);
}
}
return { InProcessRuntime: MockInProcessRuntime };
});
vi.mock("../project-engine.js", async () => {
const { InProcessRuntime } = await import("./in-process-runtime.js");
class MockProjectEngine {
private runtime: any;
constructor(config: any, centralCore: any, _options?: any) {
this.runtime = new InProcessRuntime(config, centralCore);
}
start = vi.fn(async () => { await this.runtime.start(); });
stop = vi.fn(async () => { await this.runtime.stop(); });
getRuntime = vi.fn(() => this.runtime);
getTaskStore = vi.fn(() => null);
}
return { ProjectEngine: MockProjectEngine };
});
type MockWorker = {
handlers: Map<string, (payload: unknown) => Promise<unknown> | unknown>;
onCommand: ReturnType<typeof vi.fn>;
sendEvent: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
};
type MockRuntime = {
config: ProjectRuntimeConfig;
centralCore: {
getGlobalConcurrencyState?: () => Promise<unknown>;
recordTaskCompletion?: () => Promise<void>;
};
status: RuntimeStatus;
metrics: RuntimeMetrics;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
getStatus: ReturnType<typeof vi.fn>;
getMetrics: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
emit: (event: string, ...args: unknown[]) => void;
};
const testConfig: ProjectRuntimeConfig = {
projectId: "proj_worker_test",
workingDirectory: "/tmp/test-worker",
isolationMode: "in-process",
maxConcurrent: 2,
maxWorktrees: 4,
};
async function loadWorkerModule(): Promise<MockWorker> {
await import("./child-process-worker.js");
const ipcWorker = mockState.ipcWorkers.at(-1) as MockWorker | undefined;
if (!ipcWorker) {
throw new Error("Expected child-process-worker to instantiate IpcWorker");
}
return ipcWorker;
}
function getHandler<T = unknown>(
worker: MockWorker,
type: string
): (payload: unknown) => Promise<T> {
const handler = worker.handlers.get(type);
if (!handler) {
throw new Error(`Missing handler for ${type}`);
}
return handler as (payload: unknown) => Promise<T>;
}
describe("child-process-worker", () => {
type SignalListener = (...args: unknown[]) => void;
const originalProcessSend = process.send;
let sigtermBaseline: SignalListener[] = [];
let sigintBaseline: SignalListener[] = [];
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockState.ipcWorkers.length = 0;
mockState.runtimes.length = 0;
sigtermBaseline = process.listeners("SIGTERM") as unknown as SignalListener[];
sigintBaseline = process.listeners("SIGINT") as unknown as SignalListener[];
(process as NodeJS.Process & { send?: (...args: unknown[]) => unknown }).send = vi.fn(() => true);
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
});
afterEach(() => {
for (const listener of process.listeners("SIGTERM")) {
if (!sigtermBaseline.some((l) => l === listener)) {
process.removeListener("SIGTERM", listener as unknown as SignalListener);
}
}
for (const listener of process.listeners("SIGINT")) {
if (!sigintBaseline.some((l) => l === listener)) {
process.removeListener("SIGINT", listener as unknown as SignalListener);
}
}
if (originalProcessSend) {
process.send = originalProcessSend;
} else {
delete (process as NodeJS.Process & { send?: unknown }).send;
}
vi.restoreAllMocks();
});
it("instantiates IpcWorker and registers START/STOP/GET_STATUS/GET_METRICS handlers", async () => {
const worker = await loadWorkerModule();
expect(mockState.ipcWorkers).toHaveLength(1);
expect(worker.onCommand).toHaveBeenCalledTimes(4);
expect(worker.onCommand).toHaveBeenCalledWith(START_RUNTIME, expect.any(Function));
expect(worker.onCommand).toHaveBeenCalledWith(STOP_RUNTIME, expect.any(Function));
expect(worker.onCommand).toHaveBeenCalledWith(GET_STATUS, expect.any(Function));
expect(worker.onCommand).toHaveBeenCalledWith(GET_METRICS, expect.any(Function));
expect(worker.handlers.size).toBe(4);
});
it("START_RUNTIME creates and starts InProcessRuntime, then returns status", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler<{ status: RuntimeStatus }>(worker, START_RUNTIME);
const result = await startHandler({ config: testConfig });
expect(result).toEqual({ status: "active" });
expect(mockState.runtimes).toHaveLength(1);
const runtime = mockState.runtimes[0] as MockRuntime;
expect(runtime.config).toEqual(testConfig);
expect(runtime.start).toHaveBeenCalledTimes(1);
expect(runtime.getStatus).toHaveBeenCalled();
expect(typeof runtime.centralCore.getGlobalConcurrencyState).toBe("function");
expect(typeof runtime.centralCore.recordTaskCompletion).toBe("function");
});
it("START_RUNTIME throws if runtime is already started", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
await startHandler({ config: testConfig });
await expect(startHandler({ config: testConfig })).rejects.toThrow("Runtime already started");
});
it("START_RUNTIME forwards runtime events via ipcWorker.sendEvent", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
await startHandler({ config: testConfig });
const runtime = mockState.runtimes[0] as MockRuntime;
const task = {
id: "FN-1279",
title: "task",
description: "desc",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
size: "M",
reviewLevel: 1,
log: [],
attachments: [],
};
runtime.emit("task:created", task);
runtime.emit("task:moved", { task, from: "todo", to: "in-progress" });
runtime.emit("task:updated", task);
const runtimeError = new Error("runtime boom") as Error & { code?: string };
runtimeError.code = "RUNTIME_ERR";
runtime.emit("error", runtimeError);
runtime.emit("health-changed", { status: "active", previous: "starting" });
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_CREATED", { task });
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_MOVED", {
task,
from: "todo",
to: "in-progress",
});
expect(worker.sendEvent).toHaveBeenCalledWith("TASK_UPDATED", { task });
expect(worker.sendEvent).toHaveBeenCalledWith(ERROR_EVENT, {
message: "runtime boom",
code: "RUNTIME_ERR",
});
expect(worker.sendEvent).toHaveBeenCalledWith("HEALTH_CHANGED", {
status: "active",
previous: "starting",
});
});
it("STOP_RUNTIME stops existing runtime and returns stopped true", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
const stopHandler = getHandler<{ stopped: boolean }>(worker, STOP_RUNTIME);
await startHandler({ config: testConfig });
const runtime = mockState.runtimes[0] as MockRuntime;
const result = await stopHandler({ timeoutMs: 12345 });
expect(result).toEqual({ stopped: true });
expect(runtime.stop).toHaveBeenCalledTimes(1);
});
it("STOP_RUNTIME throws when runtime has not been started", async () => {
const worker = await loadWorkerModule();
const stopHandler = getHandler(worker, STOP_RUNTIME);
await expect(stopHandler({ timeoutMs: 30000 })).rejects.toThrow("Runtime not started");
});
it("GET_STATUS returns stopped when runtime is null", async () => {
const worker = await loadWorkerModule();
const getStatusHandler = getHandler<{ status: RuntimeStatus }>(worker, GET_STATUS);
await expect(getStatusHandler({})).resolves.toEqual({ status: "stopped" });
});
it("GET_STATUS returns runtime status when runtime exists", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
const getStatusHandler = getHandler<{ status: RuntimeStatus }>(worker, GET_STATUS);
await startHandler({ config: testConfig });
const runtime = mockState.runtimes[0] as MockRuntime;
runtime.status = "paused";
await expect(getStatusHandler({})).resolves.toEqual({ status: "paused" });
});
it("GET_METRICS returns default metrics when runtime is null", async () => {
const worker = await loadWorkerModule();
const getMetricsHandler = getHandler<RuntimeMetrics>(worker, GET_METRICS);
const result = await getMetricsHandler({});
expect(result.inFlightTasks).toBe(0);
expect(result.activeAgents).toBe(0);
expect(typeof result.lastActivityAt).toBe("string");
});
it("GET_METRICS returns runtime metrics when runtime exists", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
const getMetricsHandler = getHandler<RuntimeMetrics>(worker, GET_METRICS);
await startHandler({ config: testConfig });
const runtime = mockState.runtimes[0] as MockRuntime;
runtime.metrics = {
inFlightTasks: 7,
activeAgents: 4,
lastActivityAt: "2026-04-08T05:00:00.000Z",
};
await expect(getMetricsHandler({})).resolves.toEqual(runtime.metrics);
expect(runtime.getMetrics).toHaveBeenCalledTimes(1);
});
it("SIGTERM stops runtime and shuts down IPC worker", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
await startHandler({ config: testConfig });
const runtime = mockState.runtimes[0] as MockRuntime;
process.emit("SIGTERM");
await vi.waitFor(() => {
expect(runtime.stop).toHaveBeenCalledTimes(1);
});
await vi.waitFor(() => {
expect(worker.shutdown).toHaveBeenCalledTimes(1);
});
});
it("SIGINT stops runtime and shuts down IPC worker", async () => {
const worker = await loadWorkerModule();
const startHandler = getHandler(worker, START_RUNTIME);
await startHandler({ config: testConfig });
const runtime = mockState.runtimes[0] as MockRuntime;
process.emit("SIGINT");
await vi.waitFor(() => {
expect(runtime.stop).toHaveBeenCalledTimes(1);
});
await vi.waitFor(() => {
expect(worker.shutdown).toHaveBeenCalledTimes(1);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,334 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeMetrics } from "../project-runtime.js";
import { RemoteNodeClient } from "./remote-node-client.js";
const BASE_URL = "https://node.example.com";
const API_KEY = "secret-token";
describe("RemoteNodeClient", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
vi.useRealTimers();
});
it("health() parses successful response and sends auth header", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 123 }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
const health = await client.health();
expect(health).toEqual({ status: "ok", version: "1.0.0", uptime: 123 });
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/health`, expect.objectContaining({
method: "GET",
headers: expect.objectContaining({
Authorization: `Bearer ${API_KEY}`,
}),
}));
});
it("getMetrics() parses runtime metrics", async () => {
const metrics: RuntimeMetrics = {
inFlightTasks: 4,
activeAgents: 2,
lastActivityAt: "2026-04-08T00:00:00.000Z",
};
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(metrics), {
status: 200,
headers: { "content-type": "application/json" },
})
) as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
await expect(client.getMetrics()).resolves.toEqual(metrics);
});
it("createTask() sends POST with JSON body", async () => {
const createdTask = {
id: "KB-001",
description: "Create me",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
status: "pending",
log: [],
attachments: [],
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
size: "M",
reviewLevel: 1,
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(createdTask), {
status: 200,
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
await client.createTask({ description: "Create me" });
const options = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(fetchMock).toHaveBeenCalledWith(`${BASE_URL}/api/tasks`, expect.any(Object));
expect(options.method).toBe("POST");
expect(options.headers).toEqual(expect.objectContaining({
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
}));
expect(options.body).toBe(JSON.stringify({ description: "Create me" }));
});
it("listTasks() sends optional query params", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
await client.listTasks({ column: "in-progress", limit: 10 });
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/api/tasks?column=in-progress&limit=10`,
expect.objectContaining({ method: "GET" })
);
});
it("executeTask() posts to execute endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ acknowledged: true }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
const result = await client.executeTask("KB-123");
expect(result).toEqual({ acknowledged: true });
expect(fetchMock).toHaveBeenCalledWith(
`${BASE_URL}/api/tasks/KB-123/execute`,
expect.objectContaining({ method: "POST" })
);
});
it("streamEvents() yields parsed events from SSE stream", async () => {
const sseBody = [
"event: task:created",
'data: {"type":"task:created","payload":{"id":"KB-1"},"timestamp":"2026-04-08T00:00:00.000Z"}',
"",
"event: task:updated",
'data: {"type":"task:updated","payload":{"id":"KB-1","column":"in-progress"},"timestamp":"2026-04-08T00:01:00.000Z"}',
"",
].join("\n");
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(sseBody, {
status: 200,
headers: { "content-type": "text/event-stream" },
})
) as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
const events: unknown[] = [];
for await (const event of client.streamEvents()) {
events.push(event);
}
expect(events).toEqual([
{
type: "task:created",
payload: { id: "KB-1" },
timestamp: "2026-04-08T00:00:00.000Z",
},
{
type: "task:updated",
payload: { id: "KB-1", column: "in-progress" },
timestamp: "2026-04-08T00:01:00.000Z",
},
]);
});
it("retries on network errors", async () => {
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError("network down"))
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 123 }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
const request = client.health();
const expectation = expect(request).resolves.toEqual({
status: "ok",
version: "1.0.0",
uptime: 123,
});
await vi.advanceTimersByTimeAsync(1000);
await expectation;
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not retry on 4xx responses", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "unauthorized" }), {
status: 401,
statusText: "Unauthorized",
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
await expect(client.health()).rejects.toThrow("401 Unauthorized");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("retries on 5xx responses", async () => {
vi.useFakeTimers();
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response("server error", { status: 500, statusText: "Internal Server Error" })
)
.mockResolvedValueOnce(
new Response("server error", { status: 502, statusText: "Bad Gateway" })
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 999 }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
const request = client.health();
const expectation = expect(request).resolves.toEqual({
status: "ok",
version: "1.0.0",
uptime: 999,
});
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(2000);
await expectation;
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("aborts requests after timeoutMs", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn().mockImplementation((_: unknown, init?: RequestInit) => {
return new Promise((_resolve, reject) => {
const signal = init?.signal;
signal?.addEventListener("abort", () => {
const abortError = new Error("aborted");
abortError.name = "AbortError";
reject(abortError);
});
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({
baseUrl: BASE_URL,
apiKey: API_KEY,
timeoutMs: 5,
});
const request = client.health();
const expectation = expect(request).rejects.toThrow("timed out");
await vi.runAllTimersAsync();
await expectation;
expect(fetchMock).toHaveBeenCalledTimes(4); // initial + 3 retries
});
it("sends auth header on all request methods", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ status: "ok", version: "1.0.0", uptime: 1 }), {
status: 200,
headers: { "content-type": "application/json" },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ inFlightTasks: 0, activeAgents: 0, lastActivityAt: "now" }), {
status: 200,
headers: { "content-type": "application/json" },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify([]), {
status: 200,
headers: { "content-type": "application/json" },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ acknowledged: true }), {
status: 200,
headers: { "content-type": "application/json" },
})
)
.mockResolvedValueOnce(
new Response("event: ping\ndata: {}\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
})
);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const client = new RemoteNodeClient({ baseUrl: BASE_URL, apiKey: API_KEY });
await client.health();
await client.getMetrics();
await client.listTasks();
await client.executeTask("KB-777");
for await (const _event of client.streamEvents()) {
// Drain one-response event stream
}
for (const call of fetchMock.mock.calls) {
const options = call[1] as RequestInit;
expect(options.headers).toEqual(
expect.objectContaining({
Authorization: `Bearer ${API_KEY}`,
})
);
}
});
});

View File

@@ -1,268 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NodeConfig } from "@fusion/core";
import type { RuntimeMetrics } from "../project-runtime.js";
import { RemoteNodeRuntime } from "./remote-node-runtime.js";
const mockClientConstructor = vi.hoisted(() => vi.fn());
const mockHealth = vi.hoisted(() => vi.fn());
const mockGetMetrics = vi.hoisted(() => vi.fn());
const mockStreamEvents = vi.hoisted(() => vi.fn());
vi.mock("./remote-node-client.js", () => ({
RemoteNodeClient: vi.fn().mockImplementation((options: unknown) => {
mockClientConstructor(options);
return {
health: mockHealth,
getMetrics: mockGetMetrics,
streamEvents: mockStreamEvents,
};
}),
}));
const NOW = "2026-04-08T00:00:00.000Z";
function createNode(overrides?: Partial<NodeConfig>): NodeConfig {
return {
id: "node_remote_1",
name: "Remote Node",
type: "remote",
url: "https://remote.example.com",
apiKey: "token-123",
status: "online",
maxConcurrent: 4,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
async function* idleStream(signal?: AbortSignal): AsyncIterable<unknown> {
while (!signal?.aborted) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Yield to satisfy TypeScript/ESLint generator requirements
yield;
}
async function* eventStream(events: unknown[], signal?: AbortSignal): AsyncIterable<unknown> {
for (const event of events) {
yield event;
}
while (!signal?.aborted) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
describe("RemoteNodeRuntime", () => {
beforeEach(() => {
mockClientConstructor.mockReset();
mockHealth.mockReset();
mockGetMetrics.mockReset();
mockStreamEvents.mockReset();
mockHealth.mockResolvedValue({ status: "ok", version: "1.0.0", uptime: 100 });
mockGetMetrics.mockResolvedValue({
inFlightTasks: 1,
activeAgents: 2,
lastActivityAt: NOW,
} satisfies RuntimeMetrics);
mockStreamEvents.mockImplementation(({ signal }: { signal?: AbortSignal } = {}) =>
idleStream(signal)
);
});
afterEach(async () => {
vi.clearAllMocks();
vi.useRealTimers();
});
it("start() transitions stopped -> starting -> active and starts stream", async () => {
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_1",
projectName: "Project 1",
});
const healthEvents: string[] = [];
runtime.on("health-changed", ({ status }) => {
healthEvents.push(status);
});
await runtime.start();
expect(runtime.getStatus()).toBe("active");
expect(healthEvents).toEqual(["starting", "active"]);
expect(mockHealth).toHaveBeenCalled();
expect(mockStreamEvents).toHaveBeenCalled();
expect(mockClientConstructor).toHaveBeenCalledWith({
baseUrl: "https://remote.example.com",
apiKey: "token-123",
});
await runtime.stop();
});
it("stop() transitions to stopped and is idempotent", async () => {
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_2",
projectName: "Project 2",
});
await runtime.start();
await runtime.stop();
expect(runtime.getStatus()).toBe("stopped");
await expect(runtime.stop()).resolves.toBeUndefined();
});
it("getTaskStore() throws descriptive error", () => {
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_3",
projectName: "Project 3",
});
expect(() => runtime.getTaskStore()).toThrow(
"TaskStore not accessible for remote node runtime"
);
});
it("getScheduler() throws descriptive error", () => {
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_4",
projectName: "Project 4",
});
expect(() => runtime.getScheduler()).toThrow("Scheduler not accessible for remote node runtime");
});
it("getMetrics() returns fetched metrics on success and fallback on failure", async () => {
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_5",
projectName: "Project 5",
});
await runtime.start();
expect(runtime.getMetrics()).toEqual({
inFlightTasks: 1,
activeAgents: 2,
lastActivityAt: NOW,
});
mockGetMetrics.mockRejectedValueOnce(new Error("metrics unavailable"));
runtime.getMetrics();
await Promise.resolve();
expect(runtime.getMetrics()).toEqual({
inFlightTasks: 0,
activeAgents: 0,
lastActivityAt: NOW,
});
await runtime.stop();
});
it("forwards remote task and error events", async () => {
const createdHandler = vi.fn();
const movedHandler = vi.fn();
const updatedHandler = vi.fn();
const errorHandler = vi.fn();
mockStreamEvents.mockImplementation(({ signal }: { signal?: AbortSignal } = {}) =>
eventStream(
[
{
type: "task:created",
payload: { id: "KB-1" },
timestamp: NOW,
},
{
type: "task:moved",
payload: { task: { id: "KB-1" }, from: "todo", to: "in-progress" },
timestamp: NOW,
},
{
type: "task:updated",
payload: { id: "KB-1", column: "done" },
timestamp: NOW,
},
{
type: "error",
payload: { message: "boom" },
timestamp: NOW,
},
],
signal
)
);
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_6",
projectName: "Project 6",
});
runtime.on("task:created", createdHandler);
runtime.on("task:moved", movedHandler);
runtime.on("task:updated", updatedHandler);
runtime.on("error", errorHandler);
await runtime.start();
await vi.waitFor(() => {
expect(createdHandler).toHaveBeenCalledWith({ id: "KB-1" });
expect(movedHandler).toHaveBeenCalledWith({
task: { id: "KB-1" },
from: "todo",
to: "in-progress",
});
expect(updatedHandler).toHaveBeenCalledWith({ id: "KB-1", column: "done" });
expect(errorHandler).toHaveBeenCalledWith(expect.any(Error));
});
await runtime.stop();
});
it("reconnects when stream ends unexpectedly and transitions to errored after max attempts", async () => {
mockStreamEvents.mockImplementation(async function* () {
// Immediate end to force reconnect loop.
});
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode(),
projectId: "proj_7",
projectName: "Project 7",
});
(runtime as unknown as { reconnectBaseDelayMs: number }).reconnectBaseDelayMs = 1;
(runtime as unknown as { maxReconnectDelayMs: number }).maxReconnectDelayMs = 1;
(runtime as unknown as { maxReconnectAttempts: number }).maxReconnectAttempts = 3;
await runtime.start();
await vi.waitFor(() => {
expect(runtime.getStatus()).toBe("errored");
});
expect(mockStreamEvents.mock.calls.length).toBeGreaterThanOrEqual(3);
await runtime.stop();
});
it("validates remote node config on start", async () => {
const runtime = new RemoteNodeRuntime({
nodeConfig: createNode({ type: "local", url: undefined, apiKey: undefined }),
projectId: "proj_8",
projectName: "Project 8",
});
await expect(runtime.start()).rejects.toThrow("requires a remote node configuration");
});
});

View File

@@ -1,112 +0,0 @@
/**
* Skill selection resolver for deterministic session skill sets.
*
* Computes which skills should be available in agent sessions based on:
* 1. Project execution-enabled skill patterns from settings
* 2. Optional caller-requested skill names (for per-task overrides)
*
* The resolver reads project settings files directly (read-only) and produces
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
*/
import type { ResourceDiagnostic, Skill } from "@mariozechner/pi-coding-agent";
/**
* Context for skill selection resolution.
*/
export interface SkillSelectionContext {
/**
* Absolute path to the project root for reading settings.
*/
projectRootDir: string;
/**
* Optional explicit skill names the caller wants (e.g., from task config).
* These are skill names (not IDs), matched case-insensitively against Skill.name.
*/
requestedSkillNames?: string[];
/**
* Diagnostic label for log messages (e.g., "executor", "triage", "reviewer").
*/
sessionPurpose?: string;
}
/**
* Diagnostic about a configured or requested skill.
*/
export interface SkillDiagnostic {
type: "info" | "warning" | "error";
message: string;
skillName?: string;
skillPath?: string;
}
/**
* Result of skill selection resolution.
*/
export interface SkillSelectionResult {
/**
* Set of skill file paths to include in the session.
* Used by skillsOverride to filter discovered skills.
*/
allowedSkillPaths: Set<string>;
/**
* Set of skill file paths that were explicitly excluded by project patterns.
* These paths were disabled via -prefix patterns.
* Used by skillsOverride to distinguish "disabled" (exists but excluded) from "missing" (doesn't exist).
*/
excludedSkillPaths: Set<string>;
/**
* Diagnostics about configured/requested skills.
*/
diagnostics: SkillDiagnostic[];
/**
* Whether filtering should be applied.
* false = all discovered skills pass through (no patterns configured, no requested names)
* true = skills are filtered according to allowedSkillPaths
*/
filterActive: boolean;
}
/**
* Compute deterministic skill selection from project settings and optional requested names.
*
* Resolution rules:
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
* 2. If skill patterns exist:
* - + prefix or no prefix = add to allowed set
* - - prefix = exclude from allowed set
* - Last entry wins for duplicate paths
* 3. If requestedSkillNames provided:
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
* - Case-insensitive matching against Skill.name
* 4. Diagnostics produced for:
* - Patterns that don't match discovered skills (warning)
* - Requested names not matching any discovered skill (warning)
*/
export declare function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult;
/**
* Options for skills override filtering.
* We track requested names here so we can validate against base.skills.
*/
export interface SkillsOverrideOptions {
/** Set of allowed skill paths */
allowedSkillPaths: Set<string>;
/** Set of explicitly excluded skill paths (from -patterns). If not provided, defaults to empty set. */
excludedSkillPaths?: Set<string>;
/** Whether filtering is active */
filterActive: boolean;
/** Requested skill names for diagnostic purposes */
requestedSkillNames?: string[];
/** Session purpose for log messages */
sessionPurpose?: string;
}
/**
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
*
* @param selection - The skill selection result from resolveSessionSkills
* @param options - Additional options for the override
* @returns A skillsOverride callback for DefaultResourceLoader
*/
export declare function createSkillsOverrideFromSelection(selection: SkillSelectionResult, options?: Omit<SkillsOverrideOptions, "allowedSkillPaths" | "filterActive">): (base: {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
}) => {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
};
//# sourceMappingURL=skill-resolver.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"skill-resolver.d.ts","sourceRoot":"","sources":["skill-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,+BAA+B,CAAC;AAK/E;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC;;OAEG;IACH,cAAc,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAE/B;;OAEG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;OAGG;IACH,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAE/B;;;;OAIG;IACH,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAEhC;;OAEG;IACH,WAAW,EAAE,eAAe,EAAE,CAAC;IAE/B;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;CACvB;AAqED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,oBAAoB,CA0GzF;AAID;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,iCAAiC;IACjC,iBAAiB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC/B,uGAAuG;IACvG,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,kCAAkC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB,oDAAoD;IACpD,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,uCAAuC;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,oBAAoB,EAC/B,OAAO,GAAE,IAAI,CAAC,qBAAqB,EAAE,mBAAmB,GAAG,cAAc,CAAM,GAC9E,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,WAAW,EAAE,kBAAkB,EAAE,CAAA;CAAE,KAAK;IAAE,MAAM,EAAE,KAAK,EAAE,CAAC;IAAC,WAAW,EAAE,kBAAkB,EAAE,CAAA;CAAE,CAoG1H"}

View File

@@ -1,270 +0,0 @@
/**
* Skill selection resolver for deterministic session skill sets.
*
* Computes which skills should be available in agent sessions based on:
* 1. Project execution-enabled skill patterns from settings
* 2. Optional caller-requested skill names (for per-task overrides)
*
* The resolver reads project settings files directly (read-only) and produces
* a filter set used by createFnAgent's DefaultResourceLoader.skillsOverride.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { piLog } from "./logger.js";
// ── Settings Reading ─────────────────────────────────────────────────────────
/**
* Read a JSON object from a file path.
* Returns empty object if file doesn't exist or is invalid.
*/
function readJsonObject(path) {
if (!existsSync(path)) {
return {};
}
try {
const parsed = JSON.parse(readFileSync(path, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : {};
}
catch {
return {};
}
}
/**
* Read project settings from .fusion/settings.json.
*/
function readProjectSettings(projectRootDir) {
const fusionSettings = join(projectRootDir, ".fusion", "settings.json");
if (existsSync(fusionSettings)) {
const parsed = readJsonObject(fusionSettings);
// Only return skill-relevant fields
return {
skills: Array.isArray(parsed.skills) ? parsed.skills : undefined,
packages: Array.isArray(parsed.packages) ? parsed.packages : undefined,
};
}
return {};
}
// ── Pattern Normalization ────────────────────────────────────────────────────
/**
* Normalize a skill pattern by removing the + prefix (enabled by default).
* Returns the path portion of the pattern.
*/
function normalizePattern(pattern) {
if (pattern.startsWith("+") || pattern.startsWith("-")) {
return pattern.slice(1);
}
return pattern;
}
/**
* Check if a pattern is an exclusion pattern (-prefixed).
*/
function isExclusionPattern(pattern) {
return pattern.startsWith("-");
}
// ── Main Resolution Logic ────────────────────────────────────────────────────
/**
* Compute deterministic skill selection from project settings and optional requested names.
*
* Resolution rules:
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
* 2. If skill patterns exist:
* - + prefix or no prefix = add to allowed set
* - - prefix = exclude from allowed set
* - Last entry wins for duplicate paths
* 3. If requestedSkillNames provided:
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
* - Case-insensitive matching against Skill.name
* 4. Diagnostics produced for:
* - Patterns that don't match discovered skills (warning)
* - Requested names not matching any discovered skill (warning)
*/
export function resolveSessionSkills(context) {
const { projectRootDir, requestedSkillNames } = context;
// Read project settings
const settings = readProjectSettings(projectRootDir);
// Collect all skill patterns from settings
const skillPatterns = [];
// Top-level skills patterns
if (settings.skills) {
for (const pattern of settings.skills) {
if (typeof pattern === "string") {
skillPatterns.push(pattern);
}
}
}
// Package-scoped skill patterns
if (settings.packages) {
for (const pkg of settings.packages) {
if (typeof pkg === "object" && pkg !== null && "skills" in pkg && Array.isArray(pkg.skills)) {
for (const pattern of pkg.skills) {
if (typeof pattern === "string") {
skillPatterns.push(pattern);
}
}
}
}
}
const hasPatterns = skillPatterns.length > 0;
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
// If no patterns and no requested names, no filtering needed
if (!hasPatterns && !hasRequestedNames) {
return {
allowedSkillPaths: new Set(),
excludedSkillPaths: new Set(),
diagnostics: [],
filterActive: false,
};
}
// Build allowed and excluded sets from patterns
// Last entry wins for duplicate paths: we track the "final decision" per path
const finalDecisions = new Map(); // true = allowed, false = excluded
for (const pattern of skillPatterns) {
const path = normalizePattern(pattern);
const isExclusion = isExclusionPattern(pattern);
finalDecisions.set(path, !isExclusion);
}
// Build allowed and excluded sets from final decisions
const allowedSet = new Set();
const excludedSet = new Set();
for (const [path, allowed] of finalDecisions) {
if (allowed) {
allowedSet.add(path);
}
else {
excludedSet.add(path);
}
}
// Determine if filtering is active
// filterActive is true when:
// - Patterns exist (some skills are explicitly configured)
// - OR only requested names are provided (filter to those names)
const filterActive = hasPatterns || hasRequestedNames;
// Produce diagnostics for patterns (we can't check against actual discovered skills here,
// so we note which patterns are configured)
const diagnostics = [];
if (hasPatterns) {
for (const pattern of skillPatterns) {
if (!isExclusionPattern(pattern)) {
// Note: We don't have access to discovered skills here to check if pattern matches
// The actual validation happens in createSkillsOverrideFromSelection when base.skills is available
const path = normalizePattern(pattern);
diagnostics.push({
type: "info",
message: `Configured skill pattern: ${pattern}`,
skillPath: path,
});
}
}
}
if (hasRequestedNames) {
for (const name of requestedSkillNames) {
diagnostics.push({
type: "info",
message: `Requested skill: ${name}`,
skillName: name,
});
}
}
return {
allowedSkillPaths: allowedSet,
excludedSkillPaths: excludedSet,
diagnostics,
filterActive,
};
}
/**
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
*
* @param selection - The skill selection result from resolveSessionSkills
* @param options - Additional options for the override
* @returns A skillsOverride callback for DefaultResourceLoader
*/
export function createSkillsOverrideFromSelection(selection, options = {}) {
const { allowedSkillPaths, excludedSkillPaths, filterActive } = selection;
const { requestedSkillNames, sessionPurpose } = options;
return (base) => {
// If filtering is not active, return base unchanged
if (!filterActive) {
return base;
}
// Determine the effective filter criteria
// When requestedSkillNames is provided without patterns, filter by name
// When patterns are provided, filter by file path
const hasPatterns = allowedSkillPaths.size > 0;
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
// Filter skills
// Skills must match the inclusion criteria AND not be in the exclusion list
const hasExcluded = excludedSkillPaths.size > 0;
let filteredSkills;
if (hasRequestedNames) {
// Filter by requested names (case-insensitive match)
const requestedNamesLower = new Set(requestedSkillNames.map((n) => n.toLowerCase()));
filteredSkills = base.skills.filter((skill) => requestedNamesLower.has(skill.name.toLowerCase()) && !excludedSkillPaths.has(skill.filePath));
}
else if (hasPatterns) {
// Filter by file path (in allowed set AND not in excluded set)
filteredSkills = base.skills.filter((skill) => allowedSkillPaths.has(skill.filePath) && !excludedSkillPaths.has(skill.filePath));
}
else if (hasExcluded) {
// Only exclusions set - filter out excluded skills
filteredSkills = base.skills.filter((skill) => !excludedSkillPaths.has(skill.filePath));
}
else {
// No filter criteria - this shouldn't happen if filterActive is true
filteredSkills = base.skills;
}
// Build diagnostics for missing and disabled skills
const newDiagnostics = [];
// Check for excluded paths that DO match a discovered skill (disabled)
// These are skills that exist but were explicitly excluded by project patterns
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
const discoveredPaths = new Set(base.skills.map((s) => s.filePath));
for (const excludedPath of excludedSkillPaths) {
if (discoveredPaths.has(excludedPath)) {
// Skill exists but was disabled by project patterns
// Use "warning" type since ResourceDiagnostic only supports warning|error|collision
newDiagnostics.push({
type: "warning",
message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`,
path: excludedPath,
});
}
// If the path doesn't match any discovered skill, it's not a disabled skill - it's just not relevant
}
// Check for configured patterns (allowed paths) that don't match any discovered skill
// Note: At this point, we have access to base.skills for validation
for (const allowedPath of allowedSkillPaths) {
if (!discoveredPaths.has(allowedPath)) {
// Allowed path doesn't match any discovered skill - this is a missing/invalid pattern
newDiagnostics.push({
type: "warning",
message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`,
path: allowedPath,
});
}
}
// Check for requested names that don't match any discovered skill
if (requestedSkillNames) {
const discoveredNamesLower = new Set(base.skills.map((s) => s.name.toLowerCase()));
for (const requestedName of requestedSkillNames) {
if (!discoveredNamesLower.has(requestedName.toLowerCase())) {
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
newDiagnostics.push({
type: "warning",
message: `Requested skill '${requestedName}' not found in discovered skills${purpose}`,
});
}
}
}
// Log diagnostics if any
if (newDiagnostics.length > 0) {
for (const diag of newDiagnostics) {
piLog.warn(`[skills] ${diag.type}: ${diag.message}`);
}
}
return {
skills: filteredSkills,
diagnostics: [...base.diagnostics, ...newDiagnostics],
};
};
}
//# sourceMappingURL=skill-resolver.js.map

File diff suppressed because one or more lines are too long