docs(FN-2274): update heartbeat behavior docs with stale-activation guard semantics
- Added new 'Stale-Activation Guard' section to docs/agents.md - Documents engine-level guard behavior in executeHeartbeat() - Documents assignment-trigger guard in HeartbeatTriggerScheduler - Documents dashboard API preflight validation with 409 contract - Explains stale linkage clearing behavior for persistent agent.taskId
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
* Runtime Adapter Tests
|
||||
*
|
||||
* Tests for the PaperclipRuntimeAdapter class.
|
||||
*
|
||||
* ## Mocking Strategy
|
||||
*
|
||||
* The adapter imports pi functions from a seam module (./pi-module.js) which
|
||||
* re-exports them from the engine. This allows Vitest to mock the seam directly,
|
||||
* enabling behavioral tests of the adapter's delegation to pi functions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
@@ -9,20 +15,19 @@ import { PaperclipRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
// ── Mock Modules ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockCreateFnAgent = vi.fn();
|
||||
const mockPromptWithFallback = vi.fn();
|
||||
// Use vi.hoisted() so Vitest properly handles the hoisted mock reference
|
||||
const { mockCreateFnAgent, mockPromptWithFallback, mockDescribeModel } = vi.hoisted(() => ({
|
||||
mockCreateFnAgent: vi.fn(),
|
||||
mockPromptWithFallback: vi.fn(),
|
||||
mockDescribeModel: vi.fn(),
|
||||
}));
|
||||
|
||||
// The adapter does `require("../../../packages/engine/src/pi.js")` from
|
||||
// runtime-adapter.ts — which resolves to the same absolute path as the
|
||||
// test's `../../../../` form. Register both spellings so vi.mock matches
|
||||
// whichever string the adapter's require() uses at runtime.
|
||||
const piMock = {
|
||||
// Mock the pi-module seam so the adapter uses our mock functions
|
||||
vi.mock("../pi-module.js", () => ({
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
promptWithFallback: mockPromptWithFallback,
|
||||
describeModel: vi.fn().mockReturnValue("mock/anthropic-claude"),
|
||||
};
|
||||
vi.mock("../../../../packages/engine/src/pi.js", () => piMock);
|
||||
vi.mock("../../../packages/engine/src/pi.js", () => piMock);
|
||||
describeModel: mockDescribeModel,
|
||||
}));
|
||||
|
||||
// ── Test Suite ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,6 +36,8 @@ describe("PaperclipRuntimeAdapter", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock return values
|
||||
mockDescribeModel.mockReturnValue("mock/anthropic-claude");
|
||||
adapter = new PaperclipRuntimeAdapter();
|
||||
});
|
||||
|
||||
@@ -48,11 +55,8 @@ describe("PaperclipRuntimeAdapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: The adapter loads pi.js via CommonJS `require(...)`, which vi.mock
|
||||
// does not intercept. Re-enable these tests once the adapter switches to
|
||||
// ESM imports (or use vi.doMock with a dynamic loader seam).
|
||||
describe.skip("createSession", () => {
|
||||
it("should call createFnAgent with correct options", async () => {
|
||||
describe("createSession", () => {
|
||||
it("should call createFnAgent with all options mapped correctly", async () => {
|
||||
const mockSession = { dispose: vi.fn() };
|
||||
const mockResult = { session: mockSession, sessionFile: "/path/to/session.json" };
|
||||
mockCreateFnAgent.mockResolvedValue(mockResult);
|
||||
@@ -88,7 +92,7 @@ describe("PaperclipRuntimeAdapter", () => {
|
||||
expect(result.sessionFile).toBe("/path/to/session.json");
|
||||
});
|
||||
|
||||
it("should pass through model options", async () => {
|
||||
it("should pass through model provider options", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
|
||||
await adapter.createSession({
|
||||
@@ -147,10 +151,55 @@ describe("PaperclipRuntimeAdapter", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through event handlers", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const onText = vi.fn();
|
||||
const onThinking = vi.fn();
|
||||
const onToolStart = vi.fn();
|
||||
const onToolEnd = vi.fn();
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onText,
|
||||
onThinking,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through thinking level and session manager", async () => {
|
||||
mockCreateFnAgent.mockResolvedValue({ session: {} });
|
||||
const sessionManager = { maxHistory: 100 };
|
||||
|
||||
await adapter.createSession({
|
||||
cwd: "/project",
|
||||
systemPrompt: "Test",
|
||||
defaultThinkingLevel: "medium",
|
||||
sessionManager,
|
||||
});
|
||||
|
||||
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultThinkingLevel: "medium",
|
||||
sessionManager,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("promptWithFallback", () => {
|
||||
it("should delegate to promptWithFallback from engine", async () => {
|
||||
describe("promptWithFallback", () => {
|
||||
it("should delegate to promptWithFallback from pi module with options", async () => {
|
||||
const mockSession = { id: "test-session" };
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
|
||||
@@ -160,25 +209,57 @@ describe("PaperclipRuntimeAdapter", () => {
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledWith(mockSession, "Hello", { images: [] });
|
||||
});
|
||||
|
||||
it("should work without options", async () => {
|
||||
it("should delegate to promptWithFallback without options", async () => {
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
|
||||
await adapter.promptWithFallback({} as any, "Hello");
|
||||
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledTimes(1);
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledWith({}, "Hello", undefined);
|
||||
});
|
||||
|
||||
it("should forward session object directly to pi", async () => {
|
||||
const mockSession = {
|
||||
id: "session-123",
|
||||
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
};
|
||||
mockPromptWithFallback.mockResolvedValue(undefined);
|
||||
|
||||
await adapter.promptWithFallback(mockSession as any, "Tell me a joke");
|
||||
|
||||
expect(mockPromptWithFallback).toHaveBeenCalledWith(mockSession, "Tell me a joke", undefined);
|
||||
expect(mockSession.id).toBe("session-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip("describeModel", () => {
|
||||
describe("describeModel", () => {
|
||||
it("should return model description from pi describeModel", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { describeModel } = require("../../../../packages/engine/src/pi.js");
|
||||
|
||||
const mockSession = { model: { provider: "anthropic", id: "claude-sonnet-4-5" } };
|
||||
mockDescribeModel.mockReturnValue("anthropic/claude-sonnet-4-5");
|
||||
|
||||
const result = adapter.describeModel(mockSession as any);
|
||||
|
||||
expect(describeModel).toHaveBeenCalledWith(mockSession);
|
||||
expect(result).toBe("mock/anthropic-claude"); // from mock
|
||||
expect(mockDescribeModel).toHaveBeenCalledTimes(1);
|
||||
expect(mockDescribeModel).toHaveBeenCalledWith(mockSession);
|
||||
expect(result).toBe("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("should return unknown model when session has no model", () => {
|
||||
mockDescribeModel.mockReturnValue("unknown model");
|
||||
|
||||
const result = adapter.describeModel({} as any);
|
||||
|
||||
expect(mockDescribeModel).toHaveBeenCalledWith({});
|
||||
expect(result).toBe("unknown model");
|
||||
});
|
||||
|
||||
it("should forward the session object directly to pi describeModel", () => {
|
||||
const mockSession = { id: "test-session", model: { provider: "openai", id: "gpt-4o" } };
|
||||
mockDescribeModel.mockReturnValue("openai/gpt-4o");
|
||||
|
||||
adapter.describeModel(mockSession as any);
|
||||
|
||||
expect(mockDescribeModel).toHaveBeenCalledWith(mockSession);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
78
plugins/fusion-plugin-paperclip-runtime/src/pi-module.ts
Normal file
78
plugins/fusion-plugin-paperclip-runtime/src/pi-module.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Pi Module Seam
|
||||
*
|
||||
* Provides a mockable import path for pi functions used by the PaperclipRuntimeAdapter.
|
||||
* This module creates a seam that Vitest can intercept via vi.mock().
|
||||
*
|
||||
* ## Why a Seam Module?
|
||||
*
|
||||
* The pi functions (createFnAgent, promptWithFallback, describeModel) are defined
|
||||
* in packages/engine/src/pi.ts but are not exported from the @fusion/engine public API.
|
||||
* This seam provides a controlled import path that can be mocked in tests.
|
||||
*
|
||||
* ## Mocking in Tests
|
||||
*
|
||||
* Vitest can mock this module using:
|
||||
* ```typescript
|
||||
* vi.mock("../pi-module.js", () => ({
|
||||
* createFnAgent: mockCreateFnAgent,
|
||||
* promptWithFallback: mockPromptWithFallback,
|
||||
* describeModel: mockDescribeModel,
|
||||
* }));
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ── Type Declarations ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal AgentSession type for the adapter */
|
||||
export interface PiAgentSession {
|
||||
dispose?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** Result from createFnAgent */
|
||||
export interface PiAgentResult {
|
||||
session: PiAgentSession;
|
||||
sessionFile?: string;
|
||||
}
|
||||
|
||||
/** Options for createFnAgent */
|
||||
export interface PiAgentOptions {
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
tools?: unknown;
|
||||
customTools?: unknown;
|
||||
onText?: (text: string) => void;
|
||||
onThinking?: (text: string) => void;
|
||||
onToolStart?: (toolName: string, args?: unknown) => void;
|
||||
onToolEnd?: (toolName: string, result?: unknown) => void;
|
||||
defaultProvider?: string;
|
||||
defaultModelId?: string;
|
||||
fallbackProvider?: string;
|
||||
fallbackModelId?: string;
|
||||
defaultThinkingLevel?: string;
|
||||
sessionManager?: unknown;
|
||||
skillSelection?: unknown;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
// ── Module Export (runtime resolution) ────────────────────────────────────────
|
||||
|
||||
// Use CommonJS require at runtime, which Vitest can mock when using
|
||||
// vi.mock() with a factory function. The key is that Vitest hoists vi.mock
|
||||
// calls before module evaluation, so the mock is active when require() runs.
|
||||
//
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const _piModule = require("../../../packages/engine/src/pi.js") as {
|
||||
createFnAgent: (options: PiAgentOptions) => Promise<PiAgentResult>;
|
||||
promptWithFallback: (session: PiAgentSession, prompt: string, options?: unknown) => Promise<void>;
|
||||
describeModel: (session: PiAgentSession) => string;
|
||||
};
|
||||
|
||||
/** Create a new agent session using the pi backend */
|
||||
export const createFnAgent = _piModule.createFnAgent;
|
||||
|
||||
/** Prompt the session with automatic retry and fallback */
|
||||
export const promptWithFallback = _piModule.promptWithFallback;
|
||||
|
||||
/** Get a human-readable model description from a session */
|
||||
export const describeModel = _piModule.describeModel;
|
||||
@@ -37,23 +37,18 @@ import type {
|
||||
AgentSessionResult,
|
||||
} from "./types.js";
|
||||
|
||||
// ── describeModel (from pi.ts, not re-exported from @fusion/engine) ─────────────
|
||||
// ── Pi Module Seam ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// describeModel is defined in packages/engine/src/pi.ts but is NOT exported from
|
||||
// the @fusion/engine public API. We import it via relative path for use in the adapter.
|
||||
// This is acceptable within the monorepo workspace. External plugins would need a
|
||||
// different approach (e.g., the engine could export it publicly in the future).
|
||||
// The pi functions are imported from a local seam module (pi-module.ts) which
|
||||
// re-exports them from the engine. This approach provides a mockable import path
|
||||
// for Vitest tests without relying on CommonJS require() which bypasses mocks.
|
||||
//
|
||||
type PiModule = {
|
||||
createFnAgent: (options: unknown) => Promise<AgentSessionResult>;
|
||||
promptWithFallback: (session: unknown, prompt: string, options?: unknown) => Promise<void>;
|
||||
describeModel: (session: unknown) => string;
|
||||
};
|
||||
// The seam module is at: ./pi-module.js
|
||||
//
|
||||
import { createFnAgent, promptWithFallback, describeModel } from "./pi-module.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const loadPiModule = (): PiModule => require("../../../packages/engine/src/pi.js") as PiModule;
|
||||
|
||||
const { describeModel: getModelDescription } = loadPiModule();
|
||||
/** Cached describeModel reference for synchronous describeModel() calls */
|
||||
const getModelDescription = describeModel;
|
||||
|
||||
/**
|
||||
* Paperclip runtime adapter implementing the Fusion AgentRuntime interface.
|
||||
@@ -81,7 +76,6 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
* @returns Promise resolving to the session result with session and optional sessionFile
|
||||
*/
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
const { createFnAgent } = loadPiModule();
|
||||
return createFnAgent({
|
||||
cwd: options.cwd,
|
||||
systemPrompt: options.systemPrompt,
|
||||
@@ -115,8 +109,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
* @param options - Optional prompt options (e.g., images for vision)
|
||||
*/
|
||||
async promptWithFallback(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
|
||||
const { promptWithFallback: pwf } = loadPiModule();
|
||||
return pwf(session, prompt, options);
|
||||
return promptWithFallback(session, prompt, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user