Files
openclaw-openbrain-context/tests/engine.test.ts
2026-03-06 18:14:55 +00:00

415 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { OpenBrainConfigError } from "../src/errors.js";
import { OpenBrainContextEngine } from "../src/engine.js";
import type { AgentMessage } from "../src/openclaw-types.js";
import type {
OpenBrainClientLike,
OpenBrainThought,
OpenBrainThoughtInput,
} from "../src/openbrain-client.js";
function makeThought(
id: string,
content: string,
sessionId: string,
role: string,
createdAt: string,
): OpenBrainThought {
return {
id,
content,
source: `openclaw:${sessionId}`,
metadata: {
sessionId,
role,
type: "message",
},
createdAt,
updatedAt: undefined,
score: undefined,
};
}
function makeMockClient(): OpenBrainClientLike {
return {
createThought: vi.fn(async (input: OpenBrainThoughtInput) => ({
id: `thought-${Math.random().toString(36).slice(2)}`,
content: input.content,
source: input.source,
metadata: input.metadata,
createdAt: new Date().toISOString(),
updatedAt: undefined,
score: undefined,
})),
search: vi.fn(async () => []),
listRecent: vi.fn(async () => []),
updateThought: vi.fn(async (id, payload) => ({
id,
content: payload.content ?? "",
source: "openclaw:session",
metadata: payload.metadata,
createdAt: new Date().toISOString(),
updatedAt: undefined,
score: undefined,
})),
deleteThought: vi.fn(async () => undefined),
deleteThoughts: vi.fn(async () => undefined),
};
}
const sessionId = "session-main";
const userMessage: AgentMessage = {
role: "user",
content: "What did we decide yesterday?",
timestamp: Date.now(),
};
describe("OpenBrainContextEngine", () => {
it("throws OpenBrainConfigError at bootstrap when baseUrl/apiKey are missing", async () => {
const engine = new OpenBrainContextEngine({});
await expect(
engine.bootstrap({
sessionId,
sessionFile: "/tmp/session.json",
}),
).rejects.toBeInstanceOf(OpenBrainConfigError);
});
it("ingests messages with session metadata", async () => {
const mockClient = makeMockClient();
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
source: "openclaw",
},
{
createClient: () => mockClient,
},
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.ingest({ sessionId, message: userMessage });
expect(result.ingested).toBe(true);
expect(mockClient.createThought).toHaveBeenCalledWith(
expect.objectContaining({
source: "openclaw:session-main",
metadata: expect.objectContaining({
sessionId,
role: "user",
type: "message",
turn: 0,
}),
}),
);
});
it("ingests batches and returns ingested count", async () => {
const mockClient = makeMockClient();
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.ingestBatch({
sessionId,
messages: [
{ role: "user", content: "one", timestamp: 1 },
{ role: "assistant", content: "two", timestamp: 2 },
],
});
expect(result.ingestedCount).toBe(2);
expect(mockClient.createThought).toHaveBeenCalledTimes(2);
});
it("ingests batches in parallel chunks of five", async () => {
const mockClient = makeMockClient();
let inFlight = 0;
let maxInFlight = 0;
let createdCount = 0;
vi.mocked(mockClient.createThought).mockImplementation(async (input: OpenBrainThoughtInput) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
inFlight -= 1;
createdCount += 1;
return {
id: `thought-${createdCount}`,
content: input.content,
source: input.source,
metadata: input.metadata,
createdAt: new Date().toISOString(),
updatedAt: undefined,
score: undefined,
};
});
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.ingestBatch({
sessionId,
messages: Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index + 1}`,
timestamp: index + 1,
})),
});
expect(result.ingestedCount).toBe(10);
expect(maxInFlight).toBe(5);
expect(mockClient.createThought).toHaveBeenCalledTimes(10);
});
it("assembles context from recent + semantic search, deduped and budget-aware", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue([
makeThought("t1", "recent user context", sessionId, "user", "2026-03-06T12:00:00.000Z"),
makeThought(
"t2",
"recent assistant context",
sessionId,
"assistant",
"2026-03-06T12:01:00.000Z",
),
]);
vi.mocked(mockClient.search).mockResolvedValue([
makeThought(
"t2",
"recent assistant context",
sessionId,
"assistant",
"2026-03-06T12:01:00.000Z",
),
makeThought("t3", "semantic match", sessionId, "assistant", "2026-03-06T12:02:00.000Z"),
]);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
recentMessages: 10,
semanticSearchLimit: 10,
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.assemble({
sessionId,
messages: [
{
role: "user",
content: "Find the semantic context",
timestamp: Date.now(),
},
],
tokenBudget: 40,
});
expect(mockClient.search).toHaveBeenCalledWith(
expect.objectContaining({
query: "Find the semantic context",
limit: 10,
}),
);
expect(result.estimatedTokens).toBeLessThanOrEqual(40);
expect(result.messages.map((message) => String(message.content))).toEqual([
"recent user context",
"recent assistant context",
"semantic match",
]);
});
it("compact archives a summary thought and deletes summarized inputs", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue(
Array.from({ length: 12 }, (_, index) => {
return makeThought(
`t${index + 1}`,
`message ${index + 1}`,
sessionId,
index % 2 === 0 ? "user" : "assistant",
`2026-03-06T12:${String(index).padStart(2, "0")}:00.000Z`,
);
}),
);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.compact({
sessionId,
sessionFile: "/tmp/session.json",
tokenBudget: 128,
});
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(mockClient.createThought).toHaveBeenCalledWith(
expect.objectContaining({
source: "openclaw:session-main",
metadata: expect.objectContaining({
sessionId,
type: "summary",
}),
}),
);
const deletedIds = vi
.mocked(mockClient.deleteThought)
.mock.calls.map(([id]) => id)
.sort((left, right) => left.localeCompare(right));
expect(deletedIds).toEqual([
"t10",
"t11",
"t12",
"t3",
"t4",
"t5",
"t6",
"t7",
"t8",
"t9",
]);
});
it("stops trimming once the newest message exceeds budget", async () => {
const mockClient = makeMockClient();
const oversizedNewest = "z".repeat(400);
vi.mocked(mockClient.listRecent).mockResolvedValue([
makeThought("t1", "small older message", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
makeThought("t2", oversizedNewest, sessionId, "assistant", "2026-03-06T12:01:00.000Z"),
]);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const result = await engine.assemble({
sessionId,
messages: [
{
role: "user",
content: "query",
timestamp: Date.now(),
},
],
tokenBudget: 12,
});
expect(result.messages.map((message) => String(message.content))).toEqual([oversizedNewest]);
});
it("prepares subagent spawn and rollback deletes seeded context", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent).mockResolvedValue([
makeThought("t1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
]);
vi.mocked(mockClient.createThought).mockResolvedValue({
id: "seed-thought",
content: "seed",
source: "openclaw:child",
metadata: undefined,
createdAt: "2026-03-06T12:01:00.000Z",
updatedAt: undefined,
score: undefined,
});
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
const prep = await engine.prepareSubagentSpawn({
parentSessionKey: sessionId,
childSessionKey: "child-session",
});
expect(prep).toBeDefined();
expect(mockClient.createThought).toHaveBeenCalledWith(
expect.objectContaining({
source: "openclaw:child-session",
}),
);
await prep?.rollback();
expect(mockClient.deleteThought).toHaveBeenCalledWith("seed-thought");
});
it("stores child outcome back into parent on subagent end", async () => {
const mockClient = makeMockClient();
vi.mocked(mockClient.listRecent)
.mockResolvedValueOnce([
makeThought("p1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
])
.mockResolvedValueOnce([
makeThought("c1", "child result detail", "child-session", "assistant", "2026-03-06T12:05:00.000Z"),
]);
const engine = new OpenBrainContextEngine(
{
baseUrl: "https://brain.example.com",
apiKey: "secret",
},
{ createClient: () => mockClient },
);
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
await engine.prepareSubagentSpawn({
parentSessionKey: sessionId,
childSessionKey: "child-session",
});
await engine.onSubagentEnded({
childSessionKey: "child-session",
reason: "completed",
});
expect(mockClient.createThought).toHaveBeenLastCalledWith(
expect.objectContaining({
source: "openclaw:session-main",
metadata: expect.objectContaining({
type: "subagent-result",
sessionId,
}),
}),
);
});
});