Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

209 lines
6.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
import { registerSearchFunction, getSearchIndex, rebuildIndex, setVectorIndex, setEmbeddingProvider, getVectorIndex } from "../src/functions/search.js";
import { VectorIndex } from "../src/state/vector-index.js";
import { KV } from "../src/state/schema.js";
import type { CompressedObservation, Session } from "../src/types.js";
function mockKV() {
const store = new Map<string, Map<string, unknown>>();
return {
get: async <T>(scope: string, key: string): Promise<T | null> => {
return (store.get(scope)?.get(key) as T) ?? null;
},
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
if (!store.has(scope)) store.set(scope, new Map());
store.get(scope)!.set(key, data);
return data;
},
delete: async (scope: string, key: string): Promise<void> => {
store.get(scope)?.delete(key);
},
list: async <T>(scope: string): Promise<T[]> => {
const entries = store.get(scope);
return entries ? (Array.from(entries.values()) as T[]) : [];
},
};
}
function mockSdk() {
const functions = new Map<string, Function>();
return {
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
functions.set(id, handler);
},
registerTrigger: () => {},
trigger: async (
idOrInput: string | { function_id: string; payload: unknown },
data?: unknown,
) => {
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
const fn = functions.get(id);
if (!fn) throw new Error(`No function: ${id}`);
return fn(payload);
},
};
}
describe("mem::search", () => {
let sdk: ReturnType<typeof mockSdk>;
let kv: ReturnType<typeof mockKV>;
beforeEach(async () => {
sdk = mockSdk();
kv = mockKV();
registerSearchFunction(sdk as never, kv as never);
const session: Session = {
id: "ses_1",
project: "demo",
cwd: "/tmp/demo",
startedAt: "2026-01-01T00:00:00Z",
status: "completed",
observationCount: 2,
};
await kv.set(KV.sessions, session.id, session);
const obsA: CompressedObservation = {
id: "obs_a",
sessionId: "ses_1",
timestamp: "2026-01-01T00:00:00Z",
type: "decision",
title: "Auth middleware decision",
subtitle: "JWT strategy",
facts: ["Use rotating refresh tokens"],
narrative: "Implemented auth middleware with JWT refresh rotation.",
concepts: ["auth", "jwt"],
files: ["src/auth.ts"],
importance: 8,
};
const obsB: CompressedObservation = {
id: "obs_b",
sessionId: "ses_1",
timestamp: "2026-01-02T00:00:00Z",
type: "file_edit",
title: "UI button styling",
facts: ["Updated primary button color"],
narrative: "Adjusted button styles in the settings page.",
concepts: ["ui", "css"],
files: ["src/ui/button.tsx"],
importance: 4,
};
await kv.set(KV.observations("ses_1"), obsA.id, obsA);
await kv.set(KV.observations("ses_1"), obsB.id, obsB);
// Module-level SearchIndex singleton would leak across tests; reset.
getSearchIndex().clear();
});
it("returns full format by default", async () => {
const result = (await sdk.trigger("mem::search", {
query: "auth middleware",
})) as { format: string; results: Array<{ observation: CompressedObservation }> };
expect(result.format).toBe("full");
expect(result.results).toHaveLength(1);
expect(result.results[0]?.observation.id).toBe("obs_a");
});
it("returns compact format when requested", async () => {
const result = (await sdk.trigger("mem::search", {
query: "auth",
format: "compact",
})) as { format: string; results: Array<{ obsId: string; title: string }> };
expect(result.format).toBe("compact");
expect(result.results[0]?.obsId).toBe("obs_a");
expect(result.results[0]?.title).toBe("Auth middleware decision");
});
it("returns narrative text and respects token budget", async () => {
const result = (await sdk.trigger("mem::search", {
query: "auth ui",
format: "narrative",
token_budget: 20,
})) as {
format: string;
results: Array<{ obsId: string }>;
text: string;
tokens_used: number;
tokens_budget: number;
truncated: boolean;
};
expect(result.format).toBe("narrative");
expect(result.tokens_budget).toBe(20);
expect(result.tokens_used).toBeLessThanOrEqual(20);
expect(typeof result.text).toBe("string");
expect(result.results.length).toBeLessThanOrEqual(2);
expect(result.truncated).toBe(true);
});
it("rejects invalid format values", async () => {
await expect(
sdk.trigger("mem::search", { query: "auth", format: "verbose" }),
).rejects.toThrow("format must be one of");
});
it("surfaces saved memories from KV.memories (#265)", async () => {
// mem::remember persists to KV.memories under a synthetic sessionId
// ("memory") that has no corresponding KV.observations entry. mem::search
// must fall back to KV.memories or memory_recall returns empty.
await kv.set(KV.memories, "mem_x1", {
id: "mem_x1",
createdAt: "2026-02-01T00:00:00Z",
updatedAt: "2026-02-01T00:00:00Z",
type: "fact",
title: "Pineapple belongs on pizza",
content: "Pineapple belongs on pizza for testing fallback path.",
concepts: ["pineapple", "pizza"],
files: [],
sessionIds: [],
strength: 7,
version: 1,
isLatest: true,
});
// Force the rebuild to pick up the new memory (mem::search only
// rebuilds on first call when idx.size === 0).
await rebuildIndex(kv as never);
const result = (await sdk.trigger("mem::search", {
query: "pineapple pizza",
format: "compact",
})) as { results: Array<{ obsId: string; title: string }> };
const hit = result.results.find((r) => r.obsId === "mem_x1");
expect(hit).toBeDefined();
expect(hit?.title).toBe("Pineapple belongs on pizza");
});
it("rebuildIndex populates the vector index", async () => {
const mockEmbedder = {
name: "test",
dimensions: 3,
embed: async (_text: string) => new Float32Array([0.1, 0.2, 0.3]),
embedBatch: async (_texts: string[]) =>
_texts.map(() => new Float32Array([0.1, 0.2, 0.3])),
};
setEmbeddingProvider(mockEmbedder);
setVectorIndex(new VectorIndex());
await rebuildIndex(kv as never);
const vi = getVectorIndex();
expect(vi).not.toBeNull();
expect(vi!.size).toBeGreaterThan(0);
// Cleanup
setVectorIndex(null);
setEmbeddingProvider(null);
});
});