f1f6516056
- Removed export leakage by demoting many helper and const symbols to module-local scope. - Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks. - Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers. - Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency. - Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals.
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
/**
|
|
* Contract tests for the three Hindsight tool factories.
|
|
*
|
|
* These exercise the public tool surface (factory gating + execute path) by
|
|
* spying on `HindsightApi.prototype.{retain, recall, reflect}` and stubbing
|
|
* Hindsight state on the fake ToolSession. We deliberately do not boot a real
|
|
* session — these tools only need a populated state accessor and Settings.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
|
import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
|
|
import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
|
|
import { HindsightRecallTool } from "@oh-my-pi/pi-coding-agent/tools/hindsight-recall";
|
|
import { HindsightReflectTool } from "@oh-my-pi/pi-coding-agent/tools/hindsight-reflect";
|
|
import { HindsightRetainTool } from "@oh-my-pi/pi-coding-agent/tools/hindsight-retain";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
|
|
|
|
const TEST_SESSION_ID = "test-session-id";
|
|
let registeredState: HindsightSessionState | undefined;
|
|
|
|
function makeConfig(overrides: Partial<HindsightConfig> = {}): HindsightConfig {
|
|
return {
|
|
hindsightApiUrl: "http://localhost:8888",
|
|
hindsightApiToken: null,
|
|
bankId: null,
|
|
bankIdPrefix: "",
|
|
scoping: "global",
|
|
bankMission: "",
|
|
retainMission: null,
|
|
autoRecall: true,
|
|
autoRetain: true,
|
|
retainMode: "full-session",
|
|
retainEveryNTurns: 3,
|
|
retainOverlapTurns: 2,
|
|
retainContext: "omp",
|
|
recallBudget: "mid",
|
|
recallMaxTokens: 1024,
|
|
recallTypes: ["world", "experience"],
|
|
recallContextTurns: 1,
|
|
recallMaxQueryChars: 800,
|
|
recallPromptPreamble: "preamble",
|
|
debug: false,
|
|
mentalModelsEnabled: false,
|
|
mentalModelAutoSeed: false,
|
|
mentalModelRefreshIntervalMs: 5 * 60 * 1000,
|
|
mentalModelMaxRenderChars: 16_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSession(settings: Settings, sessionId: string | null = TEST_SESSION_ID): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
settings,
|
|
getSessionFile: () => null,
|
|
getSessionId: () => sessionId,
|
|
getSessionSpawns: () => null,
|
|
getHindsightSessionState: () => (sessionId === TEST_SESSION_ID ? registeredState : undefined),
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
interface RegisterStateOptions {
|
|
retainTags?: string[];
|
|
recallTags?: string[];
|
|
recallTagsMatch?: "any" | "all" | "any_strict" | "all_strict";
|
|
sessionOverrides?: Record<string, unknown>;
|
|
}
|
|
|
|
function registerState(client: HindsightApi, settings?: Settings, opts: RegisterStateOptions = {}) {
|
|
registeredState = new HindsightSessionState({
|
|
sessionId: TEST_SESSION_ID,
|
|
client,
|
|
bankId: "test-bank",
|
|
retainTags: opts.retainTags,
|
|
recallTags: opts.recallTags,
|
|
recallTagsMatch: opts.recallTagsMatch,
|
|
config: makeConfig(),
|
|
session: {
|
|
sessionId: TEST_SESSION_ID,
|
|
sessionManager: { getEntries: () => [] } as never,
|
|
emitNotice: () => {},
|
|
getHindsightSessionState: () => registeredState,
|
|
...opts.sessionOverrides,
|
|
} as never,
|
|
missionsSet: new Set(),
|
|
lastRetainedTurn: 0,
|
|
hasRecalledForFirstTurn: false,
|
|
});
|
|
void settings;
|
|
}
|
|
|
|
describe("Hindsight tool factories", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("retain/recall/reflect factories return null when memory.backend !== hindsight", () => {
|
|
const settings = Settings.isolated({ "memory.backend": "local", "memories.enabled": false });
|
|
const session = makeSession(settings);
|
|
expect(HindsightRetainTool.createIf(session)).toBeNull();
|
|
expect(HindsightRecallTool.createIf(session)).toBeNull();
|
|
expect(HindsightReflectTool.createIf(session)).toBeNull();
|
|
});
|
|
|
|
it("retain/recall/reflect factories return tool instances when memory.backend === hindsight", () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const session = makeSession(settings);
|
|
expect(HindsightRetainTool.createIf(session)).toBeInstanceOf(HindsightRetainTool);
|
|
expect(HindsightRecallTool.createIf(session)).toBeInstanceOf(HindsightRecallTool);
|
|
expect(HindsightReflectTool.createIf(session)).toBeInstanceOf(HindsightReflectTool);
|
|
});
|
|
});
|
|
|
|
describe("retain.execute", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("queues the memory and reports success without calling the API", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const retainBatchSpy = vi.spyOn(HindsightApi.prototype, "retainBatch").mockResolvedValue({} as never);
|
|
const retainSpy = vi.spyOn(HindsightApi.prototype, "retain").mockResolvedValue({} as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = HindsightRetainTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-1", { items: [{ content: "user prefers tabs" }] });
|
|
|
|
expect(result.content[0]).toEqual({ type: "text", text: "1 memory queued." });
|
|
// Tool returns before any HTTP work happens.
|
|
expect(retainBatchSpy).not.toHaveBeenCalled();
|
|
expect(retainSpy).not.toHaveBeenCalled();
|
|
expect(registeredState?.retainQueue.depth).toBe(1);
|
|
});
|
|
|
|
it("flushes a multi-item tool call as a single retainBatch call with per-item context", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const retainBatchSpy = vi.spyOn(HindsightApi.prototype, "retainBatch").mockResolvedValue({} as never);
|
|
registerState(client, settings, { retainTags: ["project:pi"] });
|
|
|
|
const tool = HindsightRetainTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-batch", {
|
|
items: [{ content: "fact one" }, { content: "fact two", context: "user override" }],
|
|
});
|
|
expect(result.content[0]).toEqual({ type: "text", text: "2 memories queued." });
|
|
|
|
await registeredState?.flushRetainQueue();
|
|
|
|
expect(retainBatchSpy).toHaveBeenCalledTimes(1);
|
|
const [bankId, items, options] = retainBatchSpy.mock.calls[0];
|
|
expect(bankId).toBe("test-bank");
|
|
expect(options).toEqual(expect.objectContaining({ async: true }));
|
|
expect(items).toEqual([
|
|
expect.objectContaining({
|
|
content: "fact one",
|
|
metadata: { session_id: TEST_SESSION_ID },
|
|
tags: ["project:pi"],
|
|
}),
|
|
expect.objectContaining({
|
|
content: "fact two",
|
|
context: "user override",
|
|
metadata: { session_id: TEST_SESSION_ID },
|
|
tags: ["project:pi"],
|
|
}),
|
|
]);
|
|
expect(registeredState?.retainQueue.depth).toBe(0);
|
|
});
|
|
|
|
it("emits a UI-only warning notice when the batch flush fails", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "retainBatch").mockRejectedValue(new Error("HTTP 503"));
|
|
const noticeSpy = vi.fn();
|
|
registerState(client, settings, { sessionOverrides: { emitNotice: noticeSpy } });
|
|
|
|
const tool = HindsightRetainTool.createIf(makeSession(settings))!;
|
|
await tool.execute("call-x", { items: [{ content: "doomed fact" }] });
|
|
await registeredState?.flushRetainQueue();
|
|
|
|
expect(noticeSpy).toHaveBeenCalledTimes(1);
|
|
const [level, message, source] = noticeSpy.mock.calls[0];
|
|
expect(level).toBe("warning");
|
|
expect(source).toBe("Hindsight");
|
|
expect(message).toContain("HTTP 503");
|
|
expect(message).toContain("1 memory");
|
|
});
|
|
|
|
it("throws when no per-session state is registered", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const tool = HindsightRetainTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-2", { items: [{ content: "x" }] })).rejects.toThrow(/not initialised/i);
|
|
});
|
|
});
|
|
|
|
describe("recall.execute", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("returns the no-results sentinel when recall yields empty", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ results: [] } as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = HindsightRecallTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-3", { query: "anything" });
|
|
expect(result.content[0]).toEqual({ type: "text", text: "No relevant memories found." });
|
|
});
|
|
|
|
it("formats non-empty results with count + UTC timestamp header", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({
|
|
results: [
|
|
{ text: "fact one", type: "world", id: "1" },
|
|
{ text: "fact two", id: "2" },
|
|
],
|
|
} as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = HindsightRecallTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-4", { query: "anything" });
|
|
const block = (result.content[0] as { text: string }).text;
|
|
expect(block).toMatch(/^Found 2 relevant memories \(as of \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\)/);
|
|
expect(block).toContain("- fact one [world]");
|
|
expect(block).toContain("- fact two");
|
|
});
|
|
|
|
it("forwards recall tags + tagsMatch from session state when present", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const recallSpy = vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ results: [] } as never);
|
|
registerState(client, settings, { recallTags: ["project:pi"], recallTagsMatch: "any" });
|
|
|
|
const tool = HindsightRecallTool.createIf(makeSession(settings))!;
|
|
await tool.execute("call-tags", { query: "anything" });
|
|
|
|
expect(recallSpy).toHaveBeenCalledWith(
|
|
"test-bank",
|
|
"anything",
|
|
expect.objectContaining({ tags: ["project:pi"], tagsMatch: "any" }),
|
|
);
|
|
});
|
|
|
|
it("rethrows underlying client errors", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "recall").mockRejectedValue(new Error("HTTP 503"));
|
|
registerState(client, settings);
|
|
|
|
const tool = HindsightRecallTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-5", { query: "anything" })).rejects.toThrow(/HTTP 503/);
|
|
});
|
|
});
|
|
|
|
describe("reflect.execute", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("returns the reflect text and forwards context", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const reflectSpy = vi
|
|
.spyOn(HindsightApi.prototype, "reflect")
|
|
.mockResolvedValue({ text: "Synthesised answer" } as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = HindsightReflectTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-6", { query: "what does the user prefer?", context: "background" });
|
|
expect(reflectSpy).toHaveBeenCalledWith(
|
|
"test-bank",
|
|
"what does the user prefer?",
|
|
expect.objectContaining({ context: "background", budget: "mid" }),
|
|
);
|
|
expect((result.content[0] as { text: string }).text).toBe("Synthesised answer");
|
|
});
|
|
|
|
it("falls back to a sentinel when reflect returns blank text", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "reflect").mockResolvedValue({ text: " " } as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = HindsightReflectTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-7", { query: "anything" });
|
|
expect((result.content[0] as { text: string }).text).toBe("No relevant information found to reflect on.");
|
|
});
|
|
});
|