diff --git a/examples/openclaw-plugin/context-engine.ts b/examples/openclaw-plugin/context-engine.ts index dca39a836..5ebc1d34f 100644 --- a/examples/openclaw-plugin/context-engine.ts +++ b/examples/openclaw-plugin/context-engine.ts @@ -460,6 +460,7 @@ export function createMemoryOpenVikingContextEngine(params: { cfg: Required; logger: Logger; getClient: () => Promise; + quickPrecheck?: () => Promise<{ ok: true } | { ok: false; reason: string }>; /** Extra args help match hook-populated routing when OpenClaw provides sessionKey / OV session id. */ resolveAgentId: (sessionId: string, sessionKey?: string, ovSessionId?: string) => string; rememberSessionAgentId?: (ctx: { @@ -476,6 +477,7 @@ export function createMemoryOpenVikingContextEngine(params: { cfg, logger, getClient, + quickPrecheck, resolveAgentId, rememberSessionAgentId, } = params; @@ -543,6 +545,30 @@ export function createMemoryOpenVikingContextEngine(params: { return typeof agentId === "string" && agentId.trim() ? agentId.trim() : undefined; } + async function runLocalPrecheck( + stage: "assemble" | "afterTurn", + sessionId: string, + extra: Record = {}, + ): Promise { + if (cfg.mode !== "local" || !quickPrecheck) { + return true; + } + const result = await quickPrecheck(); + if (result.ok) { + return true; + } + warnOrInfo( + logger, + `openviking: ${stage} precheck failed for session=${sessionId}: ${result.reason}`, + ); + diag(`${stage}_skip`, sessionId, { + reason: "precheck_failed", + precheckReason: result.reason, + ...extra, + }); + return false; + } + return { info: { id, @@ -586,6 +612,11 @@ export function createMemoryOpenVikingContextEngine(params: { }); try { + if (!(await runLocalPrecheck("assemble", OVSessionId, { + tokenBudget, + }))) { + return { messages, estimatedTokens: roughEstimate(messages) }; + } const client = await getClient(); const routingRef = assembleParams.sessionId ?? sessionKey ?? OVSessionId; @@ -767,6 +798,13 @@ export function createMemoryOpenVikingContextEngine(params: { messages: newMsgFull, }); + if (!(await runLocalPrecheck("afterTurn", OVSessionId, { + totalMessages: messages.length, + newMessageCount: newCount, + prePromptMessageCount: start, + }))) { + return; + } const client = await getClient(); const turnText = newTexts.join("\n"); const sanitized = turnText.replace(/[\s\S]*?<\/relevant-memories>/gi, " ").replace(/\s+/g, " ").trim(); diff --git a/examples/openclaw-plugin/index.ts b/examples/openclaw-plugin/index.ts index e3acc4184..a47fac2ce 100644 --- a/examples/openclaw-plugin/index.ts +++ b/examples/openclaw-plugin/index.ts @@ -23,7 +23,7 @@ import { } from "./memory-ranking.js"; import { IS_WIN, - waitForHealth, + waitForHealthOrExit, quickHealthCheck, quickRecallPrecheck, withTimeout, @@ -343,6 +343,10 @@ const contextEnginePlugin = { entry.resolve = resolve; entry.reject = reject; }); + // Service startup can reject this shared promise before any hook/tool + // awaits it. Attach a sink now so expected local-startup failures do + // not surface as process-level unhandled rejections. + void entry.promise.catch(() => {}); clientPromise = entry.promise; localClientPendingPromises.set(localCacheKey, entry); } @@ -990,6 +994,10 @@ const contextEnginePlugin = { cfg, logger: api.logger, getClient, + quickPrecheck: + cfg.mode === "local" + ? () => quickRecallPrecheck(cfg.mode, cfg.baseUrl, cfg.port, localProcess) + : undefined, resolveAgentId, rememberSessionAgentId, }); @@ -1094,7 +1102,7 @@ const contextEnginePlugin = { api.logger.warn(`openviking: subprocess exited (code=${code}, signal=${signal})${out}`); }); try { - await waitForHealth(baseUrl, timeoutMs, intervalMs); + await waitForHealthOrExit(baseUrl, timeoutMs, intervalMs, child); const client = new OpenVikingClient( baseUrl, cfg.apiKey, @@ -1171,7 +1179,7 @@ const contextEnginePlugin = { api.logger.warn(`openviking: re-spawned subprocess exited (code=${code}, signal=${signal})`); }); try { - await waitForHealth(baseUrl, timeoutMs, intervalMs); + await waitForHealthOrExit(baseUrl, timeoutMs, intervalMs, child); const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs); localClientCache.set(localCacheKey, { client, process: child }); if (resolveLocalClient) { diff --git a/examples/openclaw-plugin/process-manager.ts b/examples/openclaw-plugin/process-manager.ts index 5ea090d2d..59f2ff4ec 100644 --- a/examples/openclaw-plugin/process-manager.ts +++ b/examples/openclaw-plugin/process-manager.ts @@ -29,6 +29,68 @@ export function waitForHealth(baseUrl: string, timeoutMs: number, intervalMs: nu }); } +export function waitForHealthOrExit( + baseUrl: string, + timeoutMs: number, + intervalMs: number, + child: ReturnType, +): Promise { + const exited = + child.killed || child.exitCode !== null || child.signalCode !== null; + if (exited) { + return Promise.reject( + new Error( + `OpenViking subprocess exited before health check ` + + `(code=${child.exitCode}, signal=${child.signalCode})`, + ), + ); + } + + return new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + child.off?.("error", onError); + child.off?.("exit", onExit); + }; + + const finishResolve = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + + const finishReject = (err: unknown) => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + + const onError = (err: Error) => { + finishReject(err); + }; + + const onExit = (code: number | null, signal: string | null) => { + finishReject( + new Error( + `OpenViking subprocess exited before health check ` + + `(code=${code}, signal=${signal})`, + ), + ); + }; + + child.once("error", onError); + child.once("exit", onExit); + waitForHealth(baseUrl, timeoutMs, intervalMs).then(finishResolve, finishReject); + }); +} + export function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); diff --git a/examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts b/examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts index 0fab13821..e7e7927ce 100644 --- a/examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts +++ b/examples/openclaw-plugin/tests/ut/context-engine-afterTurn.test.ts @@ -17,6 +17,8 @@ function makeEngine(opts?: { commitTokenThreshold?: number; getSession?: Record; addSessionMessageError?: Error; + cfgOverrides?: Record; + quickPrecheck?: () => Promise<{ ok: true } | { ok: false; reason: string }>; }) { const cfg = memoryOpenVikingConfigSchema.parse({ mode: "remote", @@ -26,6 +28,7 @@ function makeEngine(opts?: { ingestReplyAssist: false, commitTokenThreshold: opts?.commitTokenThreshold ?? 20000, emitStandardDiagnostics: true, + ...(opts?.cfgOverrides ?? {}), }); const logger = makeLogger(); @@ -63,6 +66,7 @@ function makeEngine(opts?: { cfg, logger, getClient, + quickPrecheck: opts?.quickPrecheck, resolveAgentId, }); @@ -108,6 +112,34 @@ describe("context-engine afterTurn()", () => { ); }); + it("skips immediately when local precheck reports OpenViking unavailable", async () => { + const quickPrecheck = vi.fn().mockResolvedValue({ + ok: false as const, + reason: "local process is not running", + }); + const { engine, client, getClient, logger } = makeEngine({ + cfgOverrides: { + mode: "local", + port: 1933, + }, + quickPrecheck, + }); + + await engine.afterTurn!({ + sessionId: "s1", + sessionFile: "", + messages: [{ role: "user", content: "hello" }], + prePromptMessageCount: 0, + }); + + expect(quickPrecheck).toHaveBeenCalledTimes(1); + expect(getClient).not.toHaveBeenCalled(); + expect(client.addSessionMessage).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("afterTurn precheck failed"), + ); + }); + it("skips when no new user/assistant messages after prePromptMessageCount", async () => { const { engine, client, logger } = makeEngine(); diff --git a/examples/openclaw-plugin/tests/ut/context-engine-assemble.test.ts b/examples/openclaw-plugin/tests/ut/context-engine-assemble.test.ts index c305c577a..358836711 100644 --- a/examples/openclaw-plugin/tests/ut/context-engine-assemble.test.ts +++ b/examples/openclaw-plugin/tests/ut/context-engine-assemble.test.ts @@ -35,21 +35,34 @@ function makeStats() { }; } -function makeEngine(contextResult: unknown) { +function makeEngine( + contextResult: unknown, + opts?: { + cfgOverrides?: Record; + quickPrecheck?: () => Promise<{ ok: true } | { ok: false; reason: string }>; + }, +) { const logger = makeLogger(); const client = { getSessionContext: vi.fn().mockResolvedValue(contextResult), } as unknown as OpenVikingClient; const getClient = vi.fn().mockResolvedValue(client); const resolveAgentId = vi.fn((sessionId: string) => `agent:${sessionId}`); + const localCfg = opts?.cfgOverrides + ? memoryOpenVikingConfigSchema.parse({ + ...cfg, + ...opts.cfgOverrides, + }) + : cfg; const engine = createMemoryOpenVikingContextEngine({ id: "openviking", name: "Context Engine (OpenViking)", version: "test", - cfg, + cfg: localCfg, logger, getClient, + quickPrecheck: opts?.quickPrecheck, resolveAgentId, }); @@ -142,6 +155,47 @@ describe("context-engine assemble()", () => { }); }); + it("falls back immediately when local precheck reports OpenViking unavailable", async () => { + const quickPrecheck = vi.fn().mockResolvedValue({ + ok: false as const, + reason: "local process is not running", + }); + const { engine, client, getClient, logger } = makeEngine( + { + latest_archive_overview: "unused", + pre_archive_abstracts: [], + messages: [], + estimatedTokens: 123, + stats: makeStats(), + }, + { + cfgOverrides: { + mode: "local", + port: 1933, + }, + quickPrecheck, + }, + ); + + const liveMessages = [{ role: "user", content: "fallback live message" }]; + const result = await engine.assemble({ + sessionId: "session-local", + messages: liveMessages, + tokenBudget: 4096, + }); + + expect(quickPrecheck).toHaveBeenCalledTimes(1); + expect(getClient).not.toHaveBeenCalled(); + expect(client.getSessionContext).not.toHaveBeenCalled(); + expect(result).toEqual({ + messages: liveMessages, + estimatedTokens: roughEstimate(liveMessages), + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("assemble precheck failed"), + ); + }); + it("emits a non-error toolResult for a running tool (not a synthetic error)", async () => { const { engine } = makeEngine({ latest_archive_overview: "", diff --git a/examples/openclaw-plugin/tests/ut/local-startup-bad-config-real.test.ts b/examples/openclaw-plugin/tests/ut/local-startup-bad-config-real.test.ts new file mode 100644 index 000000000..7a69e0cd3 --- /dev/null +++ b/examples/openclaw-plugin/tests/ut/local-startup-bad-config-real.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { localClientCache, localClientPendingPromises } from "../../client.js"; +import plugin from "../../index.js"; + +describe("local OpenViking startup with a bad config", () => { + beforeEach(() => { + localClientCache.clear(); + localClientPendingPromises.clear(); + }); + + afterEach(() => { + localClientCache.clear(); + localClientPendingPromises.clear(); + }); + + it("fails startup quickly and keeps before_prompt_build non-blocking", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "ov-bad-conf-")); + const badConfigPath = join(tempDir, "ov.conf"); + await writeFile(badConfigPath, "[broken\nthis is not valid\n", "utf8"); + + try { + const handlers = new Map unknown>(); + let service: + | { + start: () => Promise; + stop?: () => Promise | void; + } + | null = null; + const logs: Array<{ level: string; message: string }> = []; + const logger = { + debug: (message: string) => logs.push({ level: "debug", message }), + error: (message: string) => logs.push({ level: "error", message }), + info: (message: string) => logs.push({ level: "info", message }), + warn: (message: string) => logs.push({ level: "warn", message }), + }; + + plugin.register({ + logger, + on: (name, handler) => { + handlers.set(name, handler); + }, + pluginConfig: { + autoCapture: true, + autoRecall: true, + configPath: badConfigPath, + ingestReplyAssist: false, + logFindRequests: false, + mode: "local", + port: 19439, + }, + registerContextEngine: () => {}, + registerService: (entry) => { + service = entry; + }, + registerTool: () => {}, + }); + + expect(service).toBeTruthy(); + const hook = handlers.get("before_prompt_build"); + expect(hook).toBeTruthy(); + + const startAt = Date.now(); + const startOutcome = await Promise.race([ + service!.start().then( + () => ({ kind: "resolved" as const }), + (error) => ({ error: String(error), kind: "rejected" as const }), + ), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), 5_000); + }), + ]); + + expect(startOutcome.kind).toBe("rejected"); + expect(Date.now() - startAt).toBeLessThan(5_000); + expect(logs.some((entry) => entry.message.includes("local mode marked unavailable"))).toBe(true); + + const hookAt = Date.now(); + const hookOutcome = await Promise.race([ + Promise.resolve( + hook!( + { messages: [{ content: "hello memory", role: "user" }], prompt: "hello memory" }, + { agentId: "main", sessionId: "test-session", sessionKey: "agent:main:test" }, + ), + ).then(() => ({ kind: "returned" as const })), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), 1_500); + }), + ]); + + expect(hookOutcome.kind).toBe("returned"); + expect(Date.now() - hookAt).toBeLessThan(1_500); + expect(logs.some((entry) => entry.message.includes("failed to get client"))).toBe(true); + + await service?.stop?.(); + } finally { + await rm(tempDir, { force: true, recursive: true }); + } + }, 15_000); +}); diff --git a/examples/openclaw-plugin/tests/ut/local-startup-failure.test.ts b/examples/openclaw-plugin/tests/ut/local-startup-failure.test.ts new file mode 100644 index 000000000..2049dee85 --- /dev/null +++ b/examples/openclaw-plugin/tests/ut/local-startup-failure.test.ts @@ -0,0 +1,146 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("local OpenViking startup failure", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + it("fails fast when the child exits before health is ready", async () => { + class FakeChild extends EventEmitter { + stderr = new EventEmitter(); + killed = false; + exitCode: number | null = null; + signalCode: string | null = null; + + kill = vi.fn((signal: string = "SIGTERM") => { + if (this.killed || this.exitCode !== null || this.signalCode !== null) { + return true; + } + this.killed = true; + this.signalCode = signal; + this.emit("exit", null, signal); + return true; + }); + } + + const waitForHealth = vi.fn(() => new Promise(() => {})); + const prepareLocalPort = vi.fn(async () => 19433); + const resolvePythonCommand = vi.fn(() => "python3"); + const spawn = vi.fn(() => { + const child = new FakeChild(); + setTimeout(() => { + if (!child.killed && child.exitCode === null && child.signalCode === null) { + child.exitCode = 1; + child.emit("exit", 1, null); + } + }, 20); + return child; + }); + + vi.doMock("node:child_process", () => ({ + execSync: vi.fn(() => ""), + spawn, + })); + + vi.doMock("../../process-manager.js", async () => { + const actual = await vi.importActual( + "../../process-manager.js", + ); + return { + ...actual, + IS_WIN: false, + prepareLocalPort, + resolvePythonCommand, + waitForHealth, + }; + }); + + const { localClientCache, localClientPendingPromises } = await import("../../client.js"); + localClientCache.clear(); + localClientPendingPromises.clear(); + + const { default: plugin } = await import("../../index.js"); + const handlers = new Map unknown>(); + let service: + | { + start: () => Promise; + } + | null = null; + const logs: Array<{ level: string; message: string }> = []; + const unhandled: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + try { + const logger = { + debug: (message: string) => logs.push({ level: "debug", message }), + error: (message: string) => logs.push({ level: "error", message }), + info: (message: string) => logs.push({ level: "info", message }), + warn: (message: string) => logs.push({ level: "warn", message }), + }; + + plugin.register({ + logger, + on: (name, handler) => { + handlers.set(name, handler); + }, + pluginConfig: { + autoCapture: true, + autoRecall: true, + configPath: "/tmp/openclaw-ovbug/bad-ov.conf", + ingestReplyAssist: false, + logFindRequests: false, + mode: "local", + port: 19433, + }, + registerContextEngine: vi.fn(), + registerService: (entry) => { + service = entry; + }, + registerTool: vi.fn(), + }); + + expect(service).toBeTruthy(); + const hook = handlers.get("before_prompt_build"); + expect(hook).toBeTruthy(); + + const startOutcome = await Promise.race([ + service!.start().then( + () => ({ kind: "resolved" as const }), + (error) => ({ error: String(error), kind: "rejected" as const }), + ), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), 500); + }), + ]); + + expect(startOutcome.kind).toBe("rejected"); + + const hookOutcome = await Promise.race([ + Promise.resolve( + hook!( + { messages: [{ content: "hello memory", role: "user" }], prompt: "hello memory" }, + { agentId: "main", sessionId: "test-session", sessionKey: "agent:main:test" }, + ), + ).then(() => ({ kind: "returned" as const })), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), 500); + }), + ]); + + expect(hookOutcome.kind).toBe("returned"); + expect(logs.some((entry) => entry.message.includes("failed to get client"))).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); +}); diff --git a/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts b/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts new file mode 100644 index 000000000..96544073c --- /dev/null +++ b/examples/openclaw-plugin/tests/ut/plugin-normal-flow-real-server.test.ts @@ -0,0 +1,285 @@ +import { once } from "node:events"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { localClientCache, localClientPendingPromises } from "../../client.js"; +import plugin from "../../index.js"; + +type RequestRecord = { + body?: string; + method: string; + path: string; +}; + +function makeStats() { + return { + totalArchives: 0, + includedArchives: 0, + droppedArchives: 0, + failedArchives: 0, + activeTokens: 0, + archiveTokens: 0, + }; +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function json(res: ServerResponse, statusCode: number, payload: unknown): void { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(payload)); +} + +describe("plugin normal flow with healthy backend", () => { + let server: ReturnType; + let baseUrl = ""; + let requests: RequestRecord[] = []; + + beforeEach(async () => { + requests = []; + localClientCache.clear(); + localClientPendingPromises.clear(); + + server = createServer(async (req, res) => { + const method = req.method ?? "GET"; + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const body = method === "POST" ? await readBody(req) : undefined; + requests.push({ body, method, path: `${url.pathname}${url.search}` }); + + if (method === "GET" && url.pathname === "/health") { + json(res, 200, { status: "ok" }); + return; + } + + if (method === "GET" && url.pathname === "/api/v1/system/status") { + json(res, 200, { result: { user: "default" } }); + return; + } + + if (method === "POST" && url.pathname === "/api/v1/search/find") { + json(res, 200, { + result: { + memories: [ + { + uri: "viking://user/default/memories/rust-pref", + level: 2, + abstract: "User prefers Rust for backend tasks.", + score: 0.91, + }, + ], + total: 1, + }, + status: "ok", + }); + return; + } + + if (method === "GET" && url.pathname === "/api/v1/content/read") { + json(res, 200, { + result: "User prefers Rust for backend tasks.", + status: "ok", + }); + return; + } + + if ( + method === "GET" && + /^\/api\/v1\/sessions\/[^/]+\/context$/.test(url.pathname) + ) { + json(res, 200, { + result: { + latest_archive_overview: "Earlier work focused on backend stack choices.", + pre_archive_abstracts: [], + messages: [ + { + id: "msg_1", + role: "assistant", + created_at: "2026-04-01T00:00:00Z", + parts: [{ type: "text", text: "Stored answer from OpenViking." }], + }, + ], + estimatedTokens: 64, + stats: { + ...makeStats(), + activeTokens: 64, + }, + }, + status: "ok", + }); + return; + } + + if ( + method === "POST" && + /^\/api\/v1\/sessions\/[^/]+\/messages$/.test(url.pathname) + ) { + json(res, 200, { + result: { session_id: url.pathname.split("/")[4] }, + status: "ok", + }); + return; + } + + if ( + method === "GET" && + /^\/api\/v1\/sessions\/[^/]+$/.test(url.pathname) + ) { + json(res, 200, { + result: { pending_tokens: 25001 }, + status: "ok", + }); + return; + } + + if ( + method === "POST" && + /^\/api\/v1\/sessions\/[^/]+\/commit$/.test(url.pathname) + ) { + json(res, 200, { + result: { + session_id: url.pathname.split("/")[4], + status: "accepted", + task_id: "task-1", + archived: false, + }, + status: "ok", + }); + return; + } + + json(res, 404, { + error: { message: `Unhandled ${method} ${url.pathname}` }, + status: "error", + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("failed to bind mock server"); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + localClientCache.clear(); + localClientPendingPromises.clear(); + server.close(); + await once(server, "close"); + }); + + it("keeps normal prompt-build and context-engine flow working", async () => { + const handlers = new Map unknown>(); + let service: + | { + start: () => Promise; + stop?: () => Promise | void; + } + | null = null; + let contextEngineFactory: (() => unknown) | null = null; + + plugin.register({ + logger: { + debug: () => {}, + error: () => {}, + info: () => {}, + warn: () => {}, + }, + on: (name, handler) => { + handlers.set(name, handler); + }, + pluginConfig: { + autoCapture: true, + autoRecall: true, + baseUrl, + commitTokenThreshold: 20000, + ingestReplyAssist: false, + mode: "remote", + }, + registerContextEngine: (_id, factory) => { + contextEngineFactory = factory as () => unknown; + }, + registerService: (entry) => { + service = entry; + }, + registerTool: () => {}, + }); + + expect(service).toBeTruthy(); + expect(contextEngineFactory).toBeTruthy(); + + await service!.start(); + + const beforePromptBuild = handlers.get("before_prompt_build"); + expect(beforePromptBuild).toBeTruthy(); + const hookResult = await beforePromptBuild!( + { messages: [{ role: "user", content: "what backend language should we use?" }] }, + { agentId: "main", sessionId: "session-normal", sessionKey: "agent:main:normal" }, + ); + + expect(hookResult).toMatchObject({ + prependContext: expect.stringContaining("User prefers Rust for backend tasks."), + }); + + const contextEngine = contextEngineFactory!() as { + assemble: (params: { + sessionId: string; + messages: Array<{ role: string; content: string }>; + }) => Promise<{ messages: Array<{ role: string; content: unknown }> }>; + afterTurn: (params: { + sessionId: string; + sessionFile: string; + messages: Array<{ role: string; content: unknown }>; + prePromptMessageCount: number; + }) => Promise; + }; + + const assembled = await contextEngine.assemble({ + sessionId: "session-normal", + messages: [{ role: "user", content: "fallback" }], + }); + + expect(assembled.messages[0]).toEqual({ + role: "user", + content: "[Session History Summary]\nEarlier work focused on backend stack choices.", + }); + expect(assembled.messages[1]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Stored answer from OpenViking." }], + }); + + await contextEngine.afterTurn({ + sessionId: "session-normal", + sessionFile: "", + messages: [ + { role: "user", content: "Please keep using Rust." }, + { role: "assistant", content: [{ type: "text", text: "Understood." }] }, + ], + prePromptMessageCount: 0, + }); + + expect(requests.some((entry) => entry.method === "GET" && entry.path === "/health")).toBe(true); + expect( + requests.some((entry) => entry.method === "POST" && entry.path === "/api/v1/search/find"), + ).toBe(true); + expect( + requests.some((entry) => entry.method === "GET" && entry.path.startsWith("/api/v1/sessions/session-normal/context")), + ).toBe(true); + expect( + requests.some((entry) => entry.method === "POST" && entry.path === "/api/v1/sessions/session-normal/messages"), + ).toBe(true); + expect( + requests.some((entry) => entry.method === "POST" && entry.path === "/api/v1/sessions/session-normal/commit"), + ).toBe(true); + + await service?.stop?.(); + }); +});