|
| 1 | +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; |
| 2 | +import { stubGlobal } from "./helpers/stub-global.js"; |
| 3 | +import { OCGMemoryStore } from "../ocg-memory.js"; |
| 4 | +import type { FeedbackEvent } from "../ocg-memory.js"; |
| 5 | +import { Agent } from "../agent.js"; |
| 6 | +import { AgentRuntime } from "../runtime.js"; |
| 7 | +import { AgentAPIError } from "../errors.js"; |
| 8 | + |
| 9 | +// ── Helpers ────────────────────────────────────────────── |
| 10 | + |
| 11 | +/** A jest-mocked fetch returning a single canned JSON response (200 OK). */ |
| 12 | +function mockJson(body: unknown) { |
| 13 | + const fn = jest.fn(async () => ({ |
| 14 | + ok: true, |
| 15 | + status: 200, |
| 16 | + text: async () => JSON.stringify(body), |
| 17 | + })); |
| 18 | + stubGlobal("fetch", fn); |
| 19 | + return fn; |
| 20 | +} |
| 21 | + |
| 22 | +function makeStore() { |
| 23 | + return new OCGMemoryStore({ url: "https://ocg.test/", agent: "agent:a", user: "user:bob" }); |
| 24 | +} |
| 25 | + |
| 26 | +describe("OCGMemoryStore", () => { |
| 27 | + beforeEach(() => { |
| 28 | + jest.restoreAllMocks(); |
| 29 | + }); |
| 30 | + |
| 31 | + it("strips trailing slash from the OCG url and exposes config", () => { |
| 32 | + const store = new OCGMemoryStore({ url: "https://ocg.example.com/", agent: "agent:x" }); |
| 33 | + expect(store.ocgUrl).toBe("https://ocg.example.com"); |
| 34 | + expect(store.credential).toBe("OCG_PUBLIC_KEY"); |
| 35 | + expect(store.scope).toBe("user"); |
| 36 | + }); |
| 37 | + |
| 38 | + it("rejects a blank url or agent", () => { |
| 39 | + expect(() => new OCGMemoryStore({ url: " ", agent: "agent:x" })).toThrow(); |
| 40 | + expect(() => new OCGMemoryStore({ url: "https://ocg.test", agent: "" })).toThrow(); |
| 41 | + }); |
| 42 | + |
| 43 | + it("add posts a value field (not string_value / confidence) and agent/user", async () => { |
| 44 | + const fetchMock = mockJson({ key: "k1" }); |
| 45 | + const store = makeStore(); |
| 46 | + |
| 47 | + const key = await store.add({ content: "alice prefers email", metadata: { key: "pref" } }); |
| 48 | + |
| 49 | + expect(key).toBe("pref"); |
| 50 | + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; |
| 51 | + expect(url).toBe("https://ocg.test/api/v1/memories"); |
| 52 | + expect(init.method).toBe("POST"); |
| 53 | + const body = JSON.parse(init.body as string); |
| 54 | + expect(body.value).toBe("alice prefers email"); // field is "value", NOT "string_value" |
| 55 | + expect(body).not.toHaveProperty("string_value"); |
| 56 | + expect(body).not.toHaveProperty("confidence"); // confidence was removed from the API |
| 57 | + expect(body.agent).toBe("agent:a"); |
| 58 | + expect(body.user).toBe("user:bob"); |
| 59 | + expect(body.source).toBe("agent_inferred"); |
| 60 | + }); |
| 61 | + |
| 62 | + it("search folds the good/bad signal into result content", async () => { |
| 63 | + const fetchMock = mockJson({ |
| 64 | + memories: [ |
| 65 | + { |
| 66 | + key: "m1", |
| 67 | + value_preview: "use us-east-1", |
| 68 | + good_count: 2, |
| 69 | + bad_count: 1, |
| 70 | + relevance_score: 0.9, |
| 71 | + feedback_notes: [{ verdict: "bad", reason: "stale region" }], |
| 72 | + }, |
| 73 | + ], |
| 74 | + }); |
| 75 | + const store = makeStore(); |
| 76 | + |
| 77 | + const entries = await store.search("which region", 5); |
| 78 | + |
| 79 | + const [url] = fetchMock.mock.calls[0] as unknown as [string]; |
| 80 | + expect(url).toBe("https://ocg.test/api/v1/memories/search"); |
| 81 | + expect(entries).toHaveLength(1); |
| 82 | + expect(entries[0].content).toContain("[good 2 / bad 1]"); |
| 83 | + expect(entries[0].content).toContain('bad: "stale region"'); |
| 84 | + }); |
| 85 | + |
| 86 | + it("feedbackLinks hits the mint route and returns the urls", async () => { |
| 87 | + const fetchMock = mockJson({ |
| 88 | + good_url: "https://ocg.test/api/v1/feedback/GOOD", |
| 89 | + bad_url: "https://ocg.test/api/v1/feedback/BAD", |
| 90 | + expires_at: "2026-09-01T00:00:00Z", |
| 91 | + }); |
| 92 | + const store = makeStore(); |
| 93 | + |
| 94 | + const links = await store.feedbackLinks("k1"); |
| 95 | + |
| 96 | + const [url] = fetchMock.mock.calls[0] as unknown as [string]; |
| 97 | + expect(url.split("?")[0]).toBe("https://ocg.test/api/v1/memories/k1/feedback-links"); |
| 98 | + expect(links.good_url).toContain("/feedback/GOOD"); |
| 99 | + expect(links.bad_url).toContain("/feedback/BAD"); |
| 100 | + }); |
| 101 | + |
| 102 | + it("sends the bearer token when provided", async () => { |
| 103 | + const fetchMock = mockJson({ key: "k" }); |
| 104 | + const store = new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a", token: "tok-123" }); |
| 105 | + |
| 106 | + await store.add({ content: "x", metadata: { key: "k" } }); |
| 107 | + |
| 108 | + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; |
| 109 | + const headers = init.headers as Record<string, string>; |
| 110 | + expect(headers.Authorization).toBe("Bearer tok-123"); |
| 111 | + }); |
| 112 | + |
| 113 | + it("throws AgentAPIError on a non-2xx response", async () => { |
| 114 | + stubGlobal( |
| 115 | + "fetch", |
| 116 | + jest.fn(async () => ({ ok: false, status: 500, text: async () => "boom" })), |
| 117 | + ); |
| 118 | + const store = makeStore(); |
| 119 | + await expect(store.add({ content: "x", metadata: { key: "k" } })).rejects.toThrow(AgentAPIError); |
| 120 | + }); |
| 121 | +}); |
| 122 | + |
| 123 | +// ── Runtime feedbackSink worker ────────────────────────── |
| 124 | + |
| 125 | +interface PendingWorker { |
| 126 | + taskName: string; |
| 127 | + handler: (input: Record<string, unknown>) => Promise<unknown>; |
| 128 | +} |
| 129 | + |
| 130 | +function findWorker(runtime: AgentRuntime, taskName: string): PendingWorker | undefined { |
| 131 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 132 | + const workers = (runtime as any).workerManager.pendingWorkers as PendingWorker[]; |
| 133 | + return workers.find((w) => w.taskName === taskName); |
| 134 | +} |
| 135 | + |
| 136 | +describe("AgentRuntime feedbackSink worker", () => { |
| 137 | + it("registers a feedback_sink worker that rebuilds a FeedbackEvent and calls the sink", async () => { |
| 138 | + const runtime = new AgentRuntime(); |
| 139 | + const events: FeedbackEvent[] = []; |
| 140 | + const agent = new Agent({ |
| 141 | + name: "support", |
| 142 | + model: "openai/gpt-4o", |
| 143 | + semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }), |
| 144 | + feedbackSink: (ev) => { |
| 145 | + events.push(ev); |
| 146 | + }, |
| 147 | + }); |
| 148 | + |
| 149 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 150 | + await (runtime as any)._registerSystemWorkers(agent, null); |
| 151 | + |
| 152 | + const worker = findWorker(runtime, "support_feedback_sink"); |
| 153 | + expect(worker).toBeDefined(); |
| 154 | + |
| 155 | + const result = await worker!.handler({ |
| 156 | + memory_key: "conversation:sess-9", |
| 157 | + summary: "Alice is on Enterprise.", |
| 158 | + facts: ["plan=enterprise"], |
| 159 | + tags: ["billing"], |
| 160 | + good_url: "https://ocg.test/api/v1/feedback/GOOD", |
| 161 | + bad_url: "https://ocg.test/api/v1/feedback/BAD", |
| 162 | + agent: "agent:a", |
| 163 | + user: "user:alice", |
| 164 | + }); |
| 165 | + |
| 166 | + expect(result).toEqual({ delivered: true }); |
| 167 | + expect(events).toHaveLength(1); |
| 168 | + expect(events[0].memoryKey).toBe("conversation:sess-9"); |
| 169 | + expect(events[0].summary).toBe("Alice is on Enterprise."); |
| 170 | + expect(events[0].facts).toEqual(["plan=enterprise"]); |
| 171 | + expect(events[0].goodUrl).toContain("/feedback/GOOD"); |
| 172 | + expect(events[0].badUrl).toContain("/feedback/BAD"); |
| 173 | + }); |
| 174 | + |
| 175 | + it("swallows sink failures so memory never fails the run", async () => { |
| 176 | + const runtime = new AgentRuntime(); |
| 177 | + const agent = new Agent({ |
| 178 | + name: "support", |
| 179 | + model: "openai/gpt-4o", |
| 180 | + semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }), |
| 181 | + feedbackSink: () => { |
| 182 | + throw new Error("sink exploded"); |
| 183 | + }, |
| 184 | + }); |
| 185 | + |
| 186 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 187 | + await (runtime as any)._registerSystemWorkers(agent, null); |
| 188 | + const worker = findWorker(runtime, "support_feedback_sink"); |
| 189 | + expect(worker).toBeDefined(); |
| 190 | + |
| 191 | + await expect(worker!.handler({ memory_key: "k", summary: "s" })).resolves.toEqual({ |
| 192 | + delivered: false, |
| 193 | + }); |
| 194 | + }); |
| 195 | + |
| 196 | + it("registers no feedback_sink worker without a sink", async () => { |
| 197 | + const runtime = new AgentRuntime(); |
| 198 | + const agent = new Agent({ |
| 199 | + name: "plain", |
| 200 | + model: "openai/gpt-4o", |
| 201 | + semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }), |
| 202 | + }); |
| 203 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 204 | + await (runtime as any)._registerSystemWorkers(agent, null); |
| 205 | + expect(findWorker(runtime, "plain_feedback_sink")).toBeUndefined(); |
| 206 | + }); |
| 207 | + |
| 208 | + it("stores the memory attrs on the Agent", () => { |
| 209 | + const agent = new Agent({ |
| 210 | + name: "a", |
| 211 | + model: "openai/gpt-4o", |
| 212 | + semanticMemory: new OCGMemoryStore({ url: "https://ocg.test", agent: "agent:a" }), |
| 213 | + }); |
| 214 | + expect(agent.semanticMemory).toBeDefined(); |
| 215 | + expect(agent.memorySummaryModel).toBeUndefined(); // defaults to reuse the agent model |
| 216 | + expect(agent.feedbackSink).toBeUndefined(); |
| 217 | + }); |
| 218 | +}); |
0 commit comments