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:
6
packages/engine/.gitignore
vendored
Normal file
6
packages/engine/.gitignore
vendored
Normal 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
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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 () => {
|
||||
@@ -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 ──────────────────────
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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");
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
worktreePoolLog,
|
||||
reviewerLog,
|
||||
remoteNodeLog,
|
||||
} from "./logger.js";
|
||||
} from "../logger.js";
|
||||
|
||||
describe("createLogger", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
@@ -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",
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
@@ -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";
|
||||
|
||||
@@ -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() },
|
||||
}));
|
||||
|
||||
@@ -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 {
|
||||
@@ -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"]),
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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),
|
||||
@@ -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(() => {
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
BASE_DELAY_MS,
|
||||
MAX_DELAY_MS,
|
||||
BACKOFF_MULTIPLIER,
|
||||
} from "./recovery-policy.js";
|
||||
} from "../recovery-policy.js";
|
||||
|
||||
describe("computeRecoveryDecision", () => {
|
||||
afterEach(() => {
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
@@ -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 = {
|
||||
@@ -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 = {
|
||||
@@ -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.
|
||||
@@ -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" },
|
||||
@@ -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",
|
||||
@@ -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(),
|
||||
@@ -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);
|
||||
@@ -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", () => {
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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";
|
||||
@@ -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({
|
||||
@@ -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
|
||||
@@ -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(
|
||||
@@ -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", () => {
|
||||
@@ -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"))
|
||||
@@ -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 ───────────────────────────
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
6
packages/engine/src/auth-storage.d.ts
vendored
6
packages/engine/src/auth-storage.d.ts
vendored
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
26
packages/engine/src/context-limit-detector.d.ts
vendored
26
packages/engine/src/context-limit-detector.d.ts
vendored
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
86
packages/engine/src/logger.d.ts
vendored
86
packages/engine/src/logger.d.ts
vendored
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
95
packages/engine/src/pi.d.ts
vendored
95
packages/engine/src/pi.d.ts
vendored
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
112
packages/engine/src/skill-resolver.d.ts
vendored
112
packages/engine/src/skill-resolver.d.ts
vendored
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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
Reference in New Issue
Block a user