import { afterEach, describe, expect, it } from "bun:test"; import express from "express"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type Server } from "node:http"; import { registerLearningToolBridge } from "../../src/routes/learningToolBridge.js"; import { MemoryStore } from "../../src/memory/store.js"; import { SkillStore } from "../../src/skills/store.js"; import { getRuntimeSessionContext, removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js"; let server: Server | undefined; let directory: string | undefined; afterEach(async () => { if (server) await new Promise((resolve) => server!.close(() => resolve())); if (directory) await rm(directory, { recursive: true, force: true }); removeRuntimeSessionContext("learning-bridge-session"); }); describe("learning tools across process boundaries", () => { it("uses the parent session scope for list-before-write and rejects forged scope or missing context", async () => { directory = await mkdtemp(join(tmpdir(), "learning-bridge-")); const memoryStore = new MemoryStore(join(directory, "memory"), join(directory, "backup")); await memoryStore.initialize(); setRuntimeSessionContext({ sessionId: "learning-bridge-session", clientSessionId: "client", actorKey: "owner", projectKey: "project", traceId: "test", allowLearningWrite: true }); const app = express(); app.use(express.json()); registerLearningToolBridge(app, { internalToken: "TEST_ONLY", memoryStore, skillStore: new SkillStore(join(directory, "skills"), join(directory, "skills-backup")) }); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server!.once("listening", resolve)); const address = server.address() as { port: number }; const baseUrl = `http://127.0.0.1:${address.port}`; const child = Bun.spawn([process.execPath, "-e", [ 'import memory from "./.opencode/tools/memory_manager.ts";', 'import skill from "./.opencode/tools/skill_manager.ts";', 'const ctx = { sessionID: "learning-bridge-session" };', 'const call = (action, content) => memory.execute({ action, scope: "user", reason: "test", content }, ctx).then(JSON.parse);', 'const before = await call("add", "用户偏好简洁答复");', 'await call("list"); const added = await call("add", "用户偏好简洁答复");', 'const skills = JSON.parse(await skill.execute({ action: "list", skill_path: "__root__", reason: "test" }, ctx));', 'process.stdout.write(JSON.stringify({ before, added, skills }));', ].join("\n")], { cwd: process.cwd(), env: { ...process.env, AGENT_INTERNAL_TOKEN: "TEST_ONLY", TJWATER_AGENT_INTERNAL_BASE_URL: baseUrl }, stdout: "pipe", stderr: "pipe" }); const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); const result = JSON.parse(stdout); expect(result.before.decision).toBe("rejected"); expect(result.added.decision).toBe("accepted"); expect(result.skills.kind).toBe("skill"); expect(getRuntimeSessionContext("learning-bridge-session")?.memoryListReadScopes?.user).toBeTrue(); expect(await memoryStore.list("user", "owner")).toHaveLength(1); expect(await memoryStore.list("user", "other-owner")).toHaveLength(0); const post = (sessionId: string, input: unknown, token = "TEST_ONLY") => fetch(`${baseUrl}/internal/tools/memory-manager`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token }, body: JSON.stringify({ session_id: sessionId, input }) }); expect((await post("missing", { action: "list", scope: "user" })).status).toBe(404); expect((await post("learning-bridge-session", { action: "list", scope: "user", actorKey: "other-owner" })).status).toBe(400); expect((await post("learning-bridge-session", { action: "list", scope: "user" }, "wrong")).status).toBe(403); }); });