diff --git a/plugin/pi/index.ts b/plugin/pi/index.ts index 33a5523d..5dfbd8fa 100644 --- a/plugin/pi/index.ts +++ b/plugin/pi/index.ts @@ -190,6 +190,17 @@ function takeLastFetchTimeoutMethod(): string | undefined { return method; } +// Whether the most recent engramFetch actually got a 2xx back from the server. engramFetch returns +// null both for an unreachable server and for a reachable server that answered with a null JSON body +// (an empty result set); this out-of-band flag lets the tool layer tell those two apart. +let lastFetchReachedServer = false; + +function takeLastFetchReachedServer(): boolean { + const reached = lastFetchReachedServer; + lastFetchReachedServer = false; + return reached; +} + async function engramFetch(path: string, opts: FetchOptions = {}): Promise { const method = opts.method ?? "GET"; // This call's outcome supersedes any earlier one. A tool call can issue several fetches @@ -197,6 +208,7 @@ async function engramFetch(path: string, opts: FetchOptions // on the first leg would mislabel an unrelated failure on the second as "may already have // been applied", telling the agent not to retry a write that never left the machine. lastFetchTimeoutMethod = undefined; + lastFetchReachedServer = false; let res: Response | undefined; let timedOut = false; for (let attempt = 0; attempt < ENGRAM_FETCH_MAX_ATTEMPTS; attempt += 1) { @@ -241,6 +253,10 @@ async function engramFetch(path: string, opts: FetchOptions throw new EngramHttpError(message, res.status, data); } + // The server answered with a 2xx. Record that out-of-band so the tool layer can tell a genuine + // empty response (a JSON null body, e.g. a no-hit search) apart from an unreachable server — + // engramFetch returns null for both, and only the latter should surface as an outage. + lastFetchReachedServer = true; return data as TResponse; } @@ -821,10 +837,14 @@ async function executeMemoryTool(toolName: string, params: Record lastFetchTimeoutMethod }; + return { engramFetch, timedOutMethod: () => lastFetchTimeoutMethod, reachedServer: () => lastFetchReachedServer }; `, ); return factory( diff --git a/plugin/pi/test/native-tool-contract.test.mjs b/plugin/pi/test/native-tool-contract.test.mjs index 459273e2..5baeefa3 100644 --- a/plugin/pi/test/native-tool-contract.test.mjs +++ b/plugin/pi/test/native-tool-contract.test.mjs @@ -78,3 +78,87 @@ test("registered Pi-native mem_search reports native provider transport failure" await rm(NODE_MODULES, { recursive: true, force: true }); } }); + +function jsonResponse(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +// Runs mem_search against a stubbed Engram HTTP server. `searchResponder` returns the Response-like +// object for the /search request; every other endpoint the extension touches during init/project +// detection gets a permissive 200 so the search path is what the assertions actually exercise. +async function runMemSearch(searchResponder) { + const originalFetch = globalThis.fetch; + const originalUrl = process.env.ENGRAM_URL; + process.env.ENGRAM_URL = "http://127.0.0.1:17437"; + globalThis.fetch = async (url) => { + const target = String(url); + if (target.includes("/search")) return searchResponder(); + if (target.includes("/project/current")) return jsonResponse(200, { project: "gentle-agent-state" }); + return jsonResponse(200, {}); + }; + + try { + await installRuntimeStubs(); + const registeredTools = new Map(); + const pluginUrl = pathToFileURL(join(ROOT, "index.ts")); + pluginUrl.search = `?contract=${Date.now()}-${Math.random()}`; + const { default: registerEngram } = await import(pluginUrl.href); + registerEngram({ + registerTool(tool) { + registeredTools.set(tool.name, tool); + }, + on() {}, + }); + + const memSearch = registeredTools.get("mem_search"); + assert.ok(memSearch, "mem_search tool should be registered"); + + return await memSearch.execute( + "tool-call-search", + { query: "state markers", project: "gentle-agent-state" }, + undefined, + undefined, + { + cwd: ROOT, + sessionManager: { getSessionId: () => "test-session" }, + ui: { setStatus() {} }, + }, + ); + } finally { + globalThis.fetch = originalFetch; + if (originalUrl === undefined) delete process.env.ENGRAM_URL; + else process.env.ENGRAM_URL = originalUrl; + await rm(NODE_MODULES, { recursive: true, force: true }); + } +} + +test("mem_search treats a JSON null (no-hit) response as an empty result, not an outage", async () => { + const result = await runMemSearch(() => jsonResponse(200, null)); + + assert.notEqual(result.isError, true); + assert.doesNotMatch(result.content[0].text, /could not reach the Engram HTTP server/); + assert.deepEqual(result.details.data, []); +}); + +test("mem_search preserves an existing empty array response", async () => { + const result = await runMemSearch(() => jsonResponse(200, [])); + + assert.notEqual(result.isError, true); + assert.doesNotMatch(result.content[0].text, /could not reach the Engram HTTP server/); + assert.deepEqual(result.details.data, []); +}); + +test("mem_search returns populated results unchanged", async () => { + const hits = [{ id: 1, title: "state markers", type: "discovery" }]; + const result = await runMemSearch(() => jsonResponse(200, hits)); + + assert.notEqual(result.isError, true); + assert.deepEqual(result.details.data, hits); +}); + +test("mem_search surfaces an HTTP non-2xx response as an error", async () => { + const result = await runMemSearch(() => jsonResponse(500, { error: "search index offline" })); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /search index offline/); +});