- 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>
507 lines
18 KiB
TypeScript
507 lines
18 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
Agent,
|
|
AgentHeartbeatRun,
|
|
AgentPerformanceSummary,
|
|
AgentReflection,
|
|
ReflectionMetrics,
|
|
Task,
|
|
} from "@fusion/core";
|
|
|
|
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";
|
|
|
|
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
|
const mockedPromptWithFallback = vi.mocked(promptWithFallback);
|
|
|
|
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|
return {
|
|
id: "agent-1",
|
|
name: "Execution Agent",
|
|
role: "executor",
|
|
state: "active",
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T00:00:00.000Z",
|
|
metadata: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: "FN-001",
|
|
description: "Test task",
|
|
column: "done",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
log: [],
|
|
createdAt: "2026-04-08T00:00:00.000Z",
|
|
updatedAt: "2026-04-08T01:00:00.000Z",
|
|
assignedAgentId: "agent-1",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSummary(overrides: Partial<AgentPerformanceSummary> = {}): AgentPerformanceSummary {
|
|
return {
|
|
agentId: "agent-1",
|
|
totalTasksCompleted: 3,
|
|
totalTasksFailed: 1,
|
|
avgDurationMs: 120_000,
|
|
successRate: 0.75,
|
|
commonErrors: ["timeout"],
|
|
strengths: ["Clear commit boundaries"],
|
|
weaknesses: ["Improve test coverage"],
|
|
recentReflectionCount: 2,
|
|
computedAt: "2026-04-08T01:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeReflection(overrides: Partial<AgentReflection> = {}): AgentReflection {
|
|
return {
|
|
id: "reflection-1",
|
|
agentId: "agent-1",
|
|
timestamp: "2026-04-08T01:00:00.000Z",
|
|
trigger: "manual",
|
|
metrics: {
|
|
tasksCompleted: 2,
|
|
tasksFailed: 1,
|
|
avgDurationMs: 60_000,
|
|
commonErrors: ["timeout"],
|
|
},
|
|
insights: ["Tends to stall on testing"],
|
|
suggestedImprovements: ["Run targeted tests earlier"],
|
|
summary: "Performance is generally solid with room to tighten test feedback loops.",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRun(overrides: Partial<AgentHeartbeatRun> = {}): AgentHeartbeatRun {
|
|
return {
|
|
id: "run-1",
|
|
agentId: "agent-1",
|
|
startedAt: "2026-04-08T00:00:00.000Z",
|
|
endedAt: "2026-04-08T00:05:00.000Z",
|
|
status: "completed",
|
|
contextSnapshot: { taskId: "FN-001" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createMockDeps() {
|
|
const agentStore = {
|
|
getAgent: vi.fn().mockResolvedValue(makeAgent()),
|
|
getRecentRuns: vi.fn().mockResolvedValue([makeRun()]),
|
|
} as any;
|
|
|
|
const taskStore = {
|
|
listTasks: vi.fn().mockResolvedValue([makeTask()]),
|
|
} as any;
|
|
|
|
const reflectionStore = {
|
|
getPerformanceSummary: vi.fn().mockResolvedValue(makeSummary()),
|
|
getLatestReflection: vi.fn().mockResolvedValue(makeReflection()),
|
|
createReflection: vi.fn().mockImplementation(async ({
|
|
agentId,
|
|
trigger,
|
|
triggerDetail,
|
|
taskId,
|
|
metrics,
|
|
insights,
|
|
suggestedImprovements,
|
|
summary,
|
|
}: {
|
|
agentId: string;
|
|
trigger: AgentReflection["trigger"];
|
|
triggerDetail?: string;
|
|
taskId?: string;
|
|
metrics: ReflectionMetrics;
|
|
insights: string[];
|
|
suggestedImprovements: string[];
|
|
summary: string;
|
|
}) => makeReflection({
|
|
agentId,
|
|
trigger,
|
|
triggerDetail,
|
|
taskId,
|
|
metrics,
|
|
insights,
|
|
suggestedImprovements,
|
|
summary,
|
|
})),
|
|
} as any;
|
|
|
|
return { agentStore, taskStore, reflectionStore };
|
|
}
|
|
|
|
function createMockSession() {
|
|
return {
|
|
state: {},
|
|
dispose: vi.fn(),
|
|
} as any;
|
|
}
|
|
|
|
describe("AgentReflectionService", () => {
|
|
let tempRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
tempRoot = await mkdtemp(join(tmpdir(), "agent-reflection-test-"));
|
|
});
|
|
|
|
describe("buildReflectionContext", () => {
|
|
it("returns context with recent outcomes, summary, and latest reflection", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
|
|
const context = await service.buildReflectionContext("agent-1");
|
|
|
|
expect(context.agent.id).toBe("agent-1");
|
|
expect(context.recentOutcomes).toHaveLength(1);
|
|
expect(context.performanceSummary?.successRate).toBe(0.75);
|
|
expect(context.latestReflection?.id).toBe("reflection-1");
|
|
});
|
|
|
|
it("returns empty recentOutcomes when no task history exists", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([]);
|
|
agentStore.getRecentRuns.mockResolvedValue([]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const context = await service.buildReflectionContext("agent-1");
|
|
|
|
expect(context.recentOutcomes).toEqual([]);
|
|
});
|
|
|
|
it("reads instructions from instructionsText when provided", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
agentStore.getAgent.mockResolvedValue(makeAgent({ instructionsText: "Always run tests before committing." }));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const context = await service.buildReflectionContext("agent-1");
|
|
|
|
expect(context.instructions).toContain("Always run tests before committing.");
|
|
});
|
|
|
|
it("reads instructions from file when instructionsPath is set", async () => {
|
|
const instructionsDir = join(tempRoot, "agents");
|
|
const instructionsPath = join(instructionsDir, "executor.md");
|
|
await mkdir(instructionsDir, { recursive: true });
|
|
await writeFile(instructionsPath, "Prefer smaller commits with clear messages.", { encoding: "utf-8" });
|
|
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
agentStore.getAgent.mockResolvedValue(makeAgent({ instructionsPath: "agents/executor.md" }));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const context = await service.buildReflectionContext("agent-1");
|
|
|
|
expect(context.instructions).toContain("Prefer smaller commits with clear messages.");
|
|
});
|
|
|
|
it("handles missing agent gracefully", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
agentStore.getAgent.mockResolvedValue(null);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const context = await service.buildReflectionContext("agent-missing");
|
|
|
|
expect(context.agent.id).toBe("agent-missing");
|
|
expect(context.agent.name).toContain("Unknown Agent");
|
|
});
|
|
|
|
it("includes latest reflection when available", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
reflectionStore.getLatestReflection.mockResolvedValue(makeReflection({ id: "reflection-latest" }));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const context = await service.buildReflectionContext("agent-1");
|
|
|
|
expect(context.latestReflection?.id).toBe("reflection-latest");
|
|
});
|
|
});
|
|
|
|
describe("getRecentTaskOutcomes", () => {
|
|
it("returns completed outcomes for done and in-review tasks", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([
|
|
makeTask({ id: "FN-001", column: "done", assignedAgentId: "agent-1" }),
|
|
makeTask({ id: "FN-002", column: "in-review", assignedAgentId: "agent-1" }),
|
|
]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const outcomes = await service.getRecentTaskOutcomes("agent-1", 10);
|
|
|
|
expect(outcomes.map((outcome) => outcome.outcome)).toEqual(["completed", "completed"]);
|
|
});
|
|
|
|
it("returns failed outcomes when status includes failed", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([
|
|
makeTask({ id: "FN-FAIL", column: "todo", status: "failed", assignedAgentId: "agent-1" }),
|
|
]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const outcomes = await service.getRecentTaskOutcomes("agent-1", 10);
|
|
|
|
expect(outcomes).toHaveLength(1);
|
|
expect(outcomes[0]?.outcome).toBe("failed");
|
|
});
|
|
|
|
it("returns stuck outcomes when task was killed by stuck detector", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([
|
|
makeTask({
|
|
id: "FN-STUCK",
|
|
column: "todo",
|
|
status: "stuck-killed",
|
|
assignedAgentId: "agent-1",
|
|
log: [{ timestamp: "2026-04-08T00:00:00.000Z", action: "Task terminated due to stuck agent session" }],
|
|
}),
|
|
]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const outcomes = await service.getRecentTaskOutcomes("agent-1", 10);
|
|
|
|
expect(outcomes).toHaveLength(1);
|
|
expect(outcomes[0]?.outcome).toBe("stuck");
|
|
});
|
|
|
|
it("respects limit parameter", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([
|
|
makeTask({ id: "FN-001", assignedAgentId: "agent-1" }),
|
|
makeTask({ id: "FN-002", assignedAgentId: "agent-1" }),
|
|
makeTask({ id: "FN-003", assignedAgentId: "agent-1" }),
|
|
]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const outcomes = await service.getRecentTaskOutcomes("agent-1", 2);
|
|
|
|
expect(outcomes).toHaveLength(2);
|
|
});
|
|
|
|
it("returns empty array when agent has no tasks", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([makeTask({ assignedAgentId: "agent-2" })]);
|
|
agentStore.getRecentRuns.mockResolvedValue([]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const outcomes = await service.getRecentTaskOutcomes("agent-1", 10);
|
|
|
|
expect(outcomes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("extractErrorPatterns", () => {
|
|
it("returns common errors from performance summary", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
reflectionStore.getPerformanceSummary.mockResolvedValue(makeSummary({ commonErrors: ["timeout", "merge conflict"] }));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const errors = await service.extractErrorPatterns("agent-1");
|
|
|
|
expect(errors).toEqual(["timeout", "merge conflict"]);
|
|
});
|
|
|
|
it("returns empty array when no summary exists", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
reflectionStore.getPerformanceSummary.mockResolvedValue(makeSummary({
|
|
totalTasksCompleted: 0,
|
|
totalTasksFailed: 0,
|
|
avgDurationMs: 0,
|
|
successRate: 0,
|
|
commonErrors: [],
|
|
strengths: [],
|
|
weaknesses: [],
|
|
recentReflectionCount: 0,
|
|
}));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const errors = await service.extractErrorPatterns("agent-1");
|
|
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array when summary has no errors", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
reflectionStore.getPerformanceSummary.mockResolvedValue(makeSummary({ commonErrors: [] }));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const errors = await service.extractErrorPatterns("agent-1");
|
|
|
|
expect(errors).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("generateReflection", () => {
|
|
it("creates reflection when data is available", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
const session = createMockSession();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (options: any) => {
|
|
options.onText?.(JSON.stringify({
|
|
insights: ["Strong execution on scoped changes"],
|
|
suggestedImprovements: ["Run tests earlier in the cycle"],
|
|
summary: "Execution quality is strong with room to tighten feedback loops.",
|
|
}));
|
|
return { session };
|
|
});
|
|
mockedPromptWithFallback.mockResolvedValue(undefined);
|
|
|
|
const service = new AgentReflectionService({
|
|
agentStore,
|
|
taskStore,
|
|
reflectionStore,
|
|
rootDir: tempRoot,
|
|
});
|
|
|
|
const reflection = await service.generateReflection("agent-1", "manual", {
|
|
taskId: "FN-001",
|
|
triggerDetail: "manual check",
|
|
});
|
|
|
|
expect(reflection).not.toBeNull();
|
|
expect(reflectionStore.createReflection).toHaveBeenCalledTimes(1);
|
|
expect(reflectionStore.createReflection).toHaveBeenCalledWith(expect.objectContaining({
|
|
agentId: "agent-1",
|
|
trigger: "manual",
|
|
triggerDetail: "manual check",
|
|
taskId: "FN-001",
|
|
}));
|
|
expect(mockedCreateFnAgent).toHaveBeenCalledWith(expect.objectContaining({
|
|
tools: "readonly",
|
|
}));
|
|
});
|
|
|
|
it("returns null when no meaningful data exists", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
taskStore.listTasks.mockResolvedValue([]);
|
|
agentStore.getRecentRuns.mockResolvedValue([]);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const reflection = await service.generateReflection("agent-1", "manual");
|
|
|
|
expect(reflection).toBeNull();
|
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
|
expect(reflectionStore.createReflection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null on AI session failure", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
mockedCreateFnAgent.mockRejectedValue(new Error("AI unavailable"));
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
const reflection = await service.generateReflection("agent-1", "manual");
|
|
|
|
expect(reflection).toBeNull();
|
|
expect(reflectionStore.createReflection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists reflection via reflectionStore.createReflection", async () => {
|
|
const { agentStore, taskStore, reflectionStore } = createMockDeps();
|
|
const session = createMockSession();
|
|
|
|
mockedCreateFnAgent.mockImplementation(async (options: any) => {
|
|
options.onText?.(JSON.stringify({
|
|
insights: ["Insight A"],
|
|
suggestedImprovements: ["Improve B"],
|
|
summary: "Summary C",
|
|
}));
|
|
return { session };
|
|
});
|
|
mockedPromptWithFallback.mockResolvedValue(undefined);
|
|
|
|
const service = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: tempRoot });
|
|
await service.generateReflection("agent-1", "post-task", {
|
|
taskId: "FN-777",
|
|
triggerDetail: "post-task reflection",
|
|
});
|
|
|
|
expect(reflectionStore.createReflection).toHaveBeenCalledWith(expect.objectContaining({
|
|
trigger: "post-task",
|
|
taskId: "FN-777",
|
|
triggerDetail: "post-task reflection",
|
|
}));
|
|
});
|
|
});
|
|
|
|
describe("reflect_on_performance tool", () => {
|
|
it("returns formatted text when reflection succeeds", async () => {
|
|
const reflectionService = {
|
|
generateReflection: vi.fn().mockResolvedValue(makeReflection({
|
|
summary: "Reflection summary",
|
|
insights: ["Insight 1"],
|
|
suggestedImprovements: ["Improve 1"],
|
|
})),
|
|
} as any;
|
|
|
|
const tool = createReflectOnPerformanceTool(reflectionService, "agent-1");
|
|
const result = await (tool.execute as any)("tool-1", {}, {}, {}, undefined);
|
|
|
|
const content = result.content[0];
|
|
expect(content?.type).toBe("text");
|
|
if (!content || content.type !== "text") {
|
|
throw new Error("Expected text content");
|
|
}
|
|
|
|
expect(content.text).toContain("Summary: Reflection summary");
|
|
expect(content.text).toContain("Insights:");
|
|
expect(content.text).toContain("Suggested Improvements:");
|
|
});
|
|
|
|
it("returns no-data message when reflection returns null", async () => {
|
|
const reflectionService = {
|
|
generateReflection: vi.fn().mockResolvedValue(null),
|
|
} as any;
|
|
|
|
const tool = createReflectOnPerformanceTool(reflectionService, "agent-1");
|
|
const result = await (tool.execute as any)("tool-1", {}, {}, {}, undefined);
|
|
|
|
const content = result.content[0];
|
|
expect(content?.type).toBe("text");
|
|
if (!content || content.type !== "text") {
|
|
throw new Error("Expected text content");
|
|
}
|
|
|
|
expect(content.text).toBe("No reflection data available — not enough history yet.");
|
|
});
|
|
|
|
it("passes focus_area as triggerDetail", async () => {
|
|
const reflectionService = {
|
|
generateReflection: vi.fn().mockResolvedValue(makeReflection()),
|
|
} as any;
|
|
|
|
const tool = createReflectOnPerformanceTool(reflectionService, "agent-1");
|
|
await (tool.execute as any)("tool-1", { focus_area: "testing" }, {}, {}, undefined);
|
|
|
|
expect(reflectionService.generateReflection).toHaveBeenCalledWith(
|
|
"agent-1",
|
|
"manual",
|
|
{ triggerDetail: "Agent-initiated reflection focused on: testing" },
|
|
);
|
|
});
|
|
|
|
it("parameter schema accepts optional focus_area", () => {
|
|
const required = (reflectOnPerformanceParams as { required?: string[] }).required;
|
|
expect(required?.includes("focus_area") ?? false).toBe(false);
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempRoot) {
|
|
await rm(tempRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|