Files
fusion/packages/engine/src/__tests__/executor-test-helpers.ts
gsxdsm ff575f8b06 FN-5653: inject active goal context into heartbeat and executor prompts
Align heartbeat and executor prompt assembly so both lanes receive the same active-goal context block.

- inject goalContext into heartbeat and executor buildPromptLayers inputs using buildGoalContextSection
- extend goal-context regression coverage with parity tests for heartbeat/executor injection and empty-goal behavior
- update executor test helpers for goal-store access and document the shared prompt-lane behavior in architecture/agents docs

Files changed:
 docs/agents.md                                     |  1 +
 docs/architecture.md                               |  1 +
 packages/engine/src/__tests__/executor-test-helpers.ts  |  3 +
 packages/engine/src/__tests__/goal-context-injection.test.ts   | 72 ++++++++++++++++++++++
 packages/engine/src/agent-heartbeat.ts             | 12 +++-
 packages/engine/src/executor.ts                    | 12 +++-
 packages/engine/src/prompt-layers.ts               |  9 ++-
 7 files changed, 107 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-5653
Fusion-Task-Lineage: d2670585-57f7-409d-bb11-cef3448267a6
2026-05-29 20:05:14 -07:00

410 lines
16 KiB
TypeScript

import { vi } from "vitest";
import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
// Mock external dependencies
vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
compactSessionContext: vi.fn(async (session, instructions) => {
if (typeof (session as any).compact === "function") {
return (session as any).compact(instructions);
}
return null;
}),
promptWithFallback: vi.fn(async (session, prompt, options) => {
if (options === undefined) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
}),
}));
vi.mock("../reviewer.js", () => ({
reviewStep: vi.fn(),
}));
vi.mock("../logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
});
return {
createLogger: vi.fn(() => createMockLogger()),
schedulerLog: createMockLogger(),
executorLog: createMockLogger(),
planLog: createMockLogger(),
mergerLog: createMockLogger(),
worktreePoolLog: createMockLogger(),
reviewerLog: createMockLogger(),
prMonitorLog: createMockLogger(),
runtimeLog: createMockLogger(),
ipcLog: createMockLogger(),
projectManagerLog: createMockLogger(),
hybridExecutorLog: createMockLogger(),
formatError: (err: unknown) => {
if (err instanceof Error) {
const message = err.message || err.name || "Error";
const stack = err.stack;
return { message, stack, detail: stack ?? message };
}
const message = typeof err === "string" ? err : String(err);
return { message, detail: message };
},
};
});
vi.mock("../merger.js", () => ({
aiMergeTask: vi.fn(),
findWorktreeUser: vi.fn().mockResolvedValue(null),
}));
vi.mock("../agent-session-helpers.js", async () => {
const { createFnAgent } = await import("../pi.js");
return {
createResolvedAgentSession: async (options: any) => {
const result = await createFnAgent(options);
return {
session: result.session,
sessionFile: result.sessionFile,
runtimeId: "pi",
wasConfigured: false,
};
},
extractRuntimeHint: (runtimeConfig: Record<string, unknown> | undefined) => {
const hint = runtimeConfig?.runtimeHint;
return typeof hint === "string" && hint.trim().length > 0 ? hint.trim() : undefined;
},
resolveExecutorSessionModel: (
taskModelProvider: string | undefined,
taskModelId: string | undefined,
settings: Record<string, unknown> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>,
) => {
const model = typeof assignedAgentRuntimeConfig?.model === "string" ? assignedAgentRuntimeConfig.model : "";
const slash = model.indexOf("/");
if (slash > 0 && slash < model.length - 1) {
return { provider: model.slice(0, slash), modelId: model.slice(slash + 1) };
}
if (taskModelProvider && taskModelId) return { provider: taskModelProvider, modelId: taskModelId };
if (typeof settings?.executionProvider === "string" && typeof settings?.executionModelId === "string") {
return { provider: settings.executionProvider as string, modelId: settings.executionModelId as string };
}
if (typeof settings?.executionGlobalProvider === "string" && typeof settings?.executionGlobalModelId === "string") {
return { provider: settings.executionGlobalProvider as string, modelId: settings.executionGlobalModelId as string };
}
if (typeof settings?.defaultProviderOverride === "string" && typeof settings?.defaultModelIdOverride === "string") {
return { provider: settings.defaultProviderOverride as string, modelId: settings.defaultModelIdOverride as string };
}
if (typeof settings?.defaultProvider === "string" && typeof settings?.defaultModelId === "string") {
return { provider: settings.defaultProvider as string, modelId: settings.defaultModelId as string };
}
return { provider: undefined, modelId: undefined };
},
};
});
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"),
};
});
vi.mock("../worktree-pool.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../worktree-pool.js")>();
const backend = await vi.importActual<typeof import("../worktree-backend.js")>("../worktree-backend.js");
return {
...actual,
ActiveSessionWorktreeRemovalError: backend.ActiveSessionWorktreeRemovalError,
RemovalReason: backend.RemovalReason,
removeWorktree: vi.fn(actual.removeWorktree),
classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }),
describeRegisteredWorktrees: vi.fn().mockResolvedValue({ rawOutput: "", canonicalized: [] }),
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
};
});
vi.mock("../worktree-hooks.js", () => ({
installTaskWorktreeIdentityGuard: vi.fn().mockResolvedValue(undefined),
IDENTITY_GUARD_BYPASS_ENV: "FUSION_MERGER_BYPASS_IDENTITY_GUARD",
}));
vi.mock("../worktree-stale-lock.js", async () => {
const actual = await vi.importActual<typeof import("../worktree-stale-lock.js")>("../worktree-stale-lock.js");
return {
...actual,
parseIndexLockPath: vi.fn(actual.parseIndexLockPath),
classifyStaleLock: vi.fn(),
tryRemoveStaleLock: vi.fn(),
};
});
vi.mock("../worktree-stale-registration.js", async () => {
const actual = await vi.importActual<typeof import("../worktree-stale-registration.js")>("../worktree-stale-registration.js");
return {
...actual,
parseStaleRegistrationPath: vi.fn(actual.parseStaleRegistrationPath),
recoverStaleRegistration: vi.fn(),
};
});
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const { EventEmitter } = await import("node:events");
const execSyncFn = vi.fn();
const spawnFn = vi.fn((cmd: string, opts?: any) => {
const child = new EventEmitter() as any;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn();
queueMicrotask(() => {
try {
const out = execSyncFn(cmd, opts);
const stdout = out === undefined ? "" : out.toString();
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
child.exitCode = 0;
child.emit("close", 0, null);
} catch (err) {
const error = err as { stdout?: string; stderr?: string; status?: number; code?: number };
const stdout = error?.stdout?.toString?.() ?? "";
const stderr = error?.stderr?.toString?.() ?? "";
if (stdout) child.stdout.emit("data", Buffer.from(stdout));
if (stderr) child.stderr.emit("data", Buffer.from(stderr));
child.exitCode = error.status ?? error.code ?? 1;
child.emit("close", child.exitCode, null);
}
});
return child;
});
const execFn: any = vi.fn((cmd: string, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const forwardedOpts = typeof opts === "function" ? undefined : opts;
try {
const out = execSyncFn(cmd, forwardedOpts);
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err) {
if (typeof callback === "function") {
const error = err as { stdout?: string; stderr?: string };
callback(err, error?.stdout?.toString?.() ?? "", error?.stderr?.toString?.() ?? "");
}
}
});
const execFileFn: any = vi.fn((_file: string, _args: string[] | undefined, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
if (typeof callback === "function") {
callback(null, { stdout: "", stderr: "" });
}
});
execFn[promisify.custom] = (cmd: string, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: string, stderr: string) => {
if (err) {
(err as Record<string, unknown>).stdout = stdout;
(err as Record<string, unknown>).stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
execFileFn[promisify.custom] = (_file: string, _args?: string[], _opts?: any) =>
Promise.resolve({ stdout: "", stderr: "" });
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
});
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
realpathSync: vi.fn((path: string) => path),
}));
export const mockExecuteAll = vi.fn().mockResolvedValue([]);
export const mockTerminateAllSessions = vi.fn().mockResolvedValue(undefined);
export const mockCleanup = vi.fn().mockResolvedValue(undefined);
vi.mock("../step-session-executor.js", () => ({
StepSessionExecutor: vi.fn().mockImplementation(() => ({
executeAll: mockExecuteAll,
terminateAllSessions: mockTerminateAllSessions,
cleanup: mockCleanup,
})),
}));
vi.mock("../rate-limit-retry.js", () => ({
withRateLimitRetry: vi.fn((fn: () => Promise<unknown>) => fn()),
}));
vi.mock("../worktree-db-hydrate.js", () => ({
hydrateWorktreeDb: vi.fn().mockResolvedValue({
tasksCopied: 0,
documentsCopied: 0,
degraded: false,
}),
}));
vi.mock("../verification-utils.js", async () => {
const actual = await vi.importActual<typeof import("../verification-utils.js")>("../verification-utils.js");
return {
...actual,
runVerificationCommand: vi.fn(),
};
});
vi.mock("@earendil-works/pi-coding-agent", () => {
const mockSessionManager = {};
return {
SessionManager: {
create: vi.fn().mockReturnValue(mockSessionManager),
open: vi.fn().mockReturnValue(mockSessionManager),
inMemory: vi.fn().mockReturnValue(mockSessionManager),
},
ModelRegistry: vi.fn().mockImplementation(() => ({
find: vi.fn(),
refresh: vi.fn(),
})),
AuthStorage: {
create: vi.fn().mockReturnValue({}),
},
getAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
};
});
import { createFnAgent } from "../pi.js";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { generateWorktreeName } from "../worktree-names.js";
import { findWorktreeUser } from "../merger.js";
import { StepSessionExecutor } from "../step-session-executor.js";
import { withRateLimitRetry } from "../rate-limit-retry.js";
import { exec, execSync } from "node:child_process";
import { existsSync, realpathSync } from "node:fs";
import { hydrateWorktreeDb } from "../worktree-db-hydrate.js";
import { classifyTaskWorktree, describeRegisteredWorktrees, isUsableTaskWorktree } from "../worktree-pool.js";
import { classifyStaleLock, tryRemoveStaleLock } from "../worktree-stale-lock.js";
import { parseStaleRegistrationPath, recoverStaleRegistration } from "../worktree-stale-registration.js";
import { executingTaskLock } from "../active-session-registry.js";
export const mockedCreateFnAgent = vi.mocked(createFnAgent);
export const mockedSessionManager = vi.mocked(SessionManager);
export const mockedGenerateWorktreeName = vi.mocked(generateWorktreeName);
export const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
export const mockedStepSessionExecutor = vi.mocked(StepSessionExecutor);
export const mockedWithRateLimitRetry = vi.mocked(withRateLimitRetry);
export const mockedExec = vi.mocked(exec);
export const mockedExecSync = vi.mocked(execSync);
export const mockedExistsSync = vi.mocked(existsSync);
export const mockedRealpathSync = vi.mocked(realpathSync);
export const mockedHydrateWorktreeDb = vi.mocked(hydrateWorktreeDb);
export const mockedClassifyTaskWorktree = vi.mocked(classifyTaskWorktree);
export const mockedDescribeRegisteredWorktrees = vi.mocked(describeRegisteredWorktrees);
export const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
export const mockedClassifyStaleLock = vi.mocked(classifyStaleLock);
export const mockedTryRemoveStaleLock = vi.mocked(tryRemoveStaleLock);
export const mockedParseStaleRegistrationPath = vi.mocked(parseStaleRegistrationPath);
export const mockedRecoverStaleRegistration = vi.mocked(recoverStaleRegistration);
export const mockedInstallTaskWorktreeIdentityGuard = vi.mocked(installTaskWorktreeIdentityGuard);
export type EventListener = (...args: unknown[]) => void;
export function createMockStore() {
const listeners = new Map<string, EventListener[]>();
const store = {
on: vi.fn((event: string, fn: EventListener) => {
const existing = listeners.get(event) || [];
existing.push(fn);
listeners.set(event, existing);
}),
_trigger(event: string, ...args: unknown[]) {
for (const fn of listeners.get(event) || []) fn(...args);
},
emit: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
getTask: vi.fn().mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
updateTask: vi.fn().mockResolvedValue({}),
moveTask: vi.fn().mockResolvedValue({}),
handoffToReview: vi.fn().mockImplementation(async (id: string) => store.moveTask(id, "in-review")),
mergeTask: vi.fn().mockResolvedValue({}),
createTask: vi.fn().mockImplementation(async (input: Record<string, unknown>) => ({
id: "FN-002",
title: input.title,
description: input.description,
column: "triage",
dependencies: input.dependencies ?? [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
logEntry: vi.fn().mockResolvedValue(undefined),
addTaskComment: vi.fn().mockResolvedValue(undefined),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
updateSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: undefined,
}),
updateStep: vi.fn().mockResolvedValue({}),
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
setPluginWorkflowStepTemplates: vi.fn(),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getGoalStore: vi.fn().mockReturnValue({
listGoals: vi.fn().mockReturnValue([]),
}),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
clearStaleExecutionStartBranchReferences: vi.fn().mockReturnValue([]),
};
return store as any;
}
export function resetExecutorMocks() {
vi.clearAllMocks();
mockedExec.mockReset();
mockedExecSync.mockReset();
mockedIsUsableTaskWorktree.mockResolvedValue(true);
mockedClassifyTaskWorktree.mockImplementation(async (rootDir: string, worktreePath: string) => {
const usable = await mockedIsUsableTaskWorktree(rootDir, worktreePath);
return usable
? { ok: true }
: { ok: false, classification: "incomplete", reason: "missing or invalid .git metadata" };
});
mockedClassifyStaleLock.mockReset();
mockedTryRemoveStaleLock.mockReset();
mockedParseStaleRegistrationPath.mockReset();
mockedRecoverStaleRegistration.mockReset();
mockedInstallTaskWorktreeIdentityGuard.mockReset();
mockedClassifyStaleLock.mockResolvedValue({ kind: "fresh", reason: "fresh" } as any);
mockedParseStaleRegistrationPath.mockImplementation((value) => {
if (!value) return null;
const match = /'([^']+)'\s+is a missing but already registered worktree/i.exec(String(value));
return match?.[1] ?? null;
});
mockedRecoverStaleRegistration.mockResolvedValue({ recovered: true, actions: ["prune"] });
mockedInstallTaskWorktreeIdentityGuard.mockResolvedValue(undefined);
mockedTryRemoveStaleLock.mockResolvedValue({ removed: true });
mockExecuteAll.mockResolvedValue([]);
mockTerminateAllSessions.mockResolvedValue(undefined);
mockCleanup.mockResolvedValue(undefined);
// FN-4811 follow-up: the executingTaskLock is process-wide module state, so it must
// be cleared between tests or earlier tests' claims will block later tests' execute()
// calls ("expected at least 2 createFnAgent calls but got 0" / "expected not called
// but called 3 times" symptoms in executor-pause / executor-prompt tests).
executingTaskLock._clearForTest();
}