@contextableai/openclaw-memory-rebac 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -0
- package/authorization.ts +191 -0
- package/backend.ts +176 -0
- package/backends/backends.json +3 -0
- package/backends/graphiti.defaults.json +8 -0
- package/backends/graphiti.test.ts +292 -0
- package/backends/graphiti.ts +345 -0
- package/backends/registry.ts +36 -0
- package/bin/rebac-mem.ts +144 -0
- package/cli.ts +418 -0
- package/config.ts +141 -0
- package/docker/docker-compose.yml +17 -0
- package/docker/graphiti/Dockerfile +35 -0
- package/docker/graphiti/config_overlay.py +44 -0
- package/docker/graphiti/docker-compose.yml +101 -0
- package/docker/graphiti/graphiti_overlay.py +141 -0
- package/docker/graphiti/startup.py +222 -0
- package/docker/spicedb/docker-compose.yml +79 -0
- package/index.ts +711 -0
- package/openclaw.plugin.json +118 -0
- package/package.json +70 -0
- package/plugin.defaults.json +12 -0
- package/schema.zed +23 -0
- package/search.ts +139 -0
- package/spicedb.ts +355 -0
package/backend.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryBackend interface
|
|
3
|
+
*
|
|
4
|
+
* All storage-engine specifics live here. The rest of the plugin
|
|
5
|
+
* (SpiceDB auth, search orchestration, tool registration, CLI skeleton)
|
|
6
|
+
* is backend-agnostic and never imports from backends/.
|
|
7
|
+
*
|
|
8
|
+
* Implementing a new backend means satisfying this interface and adding
|
|
9
|
+
* an entry to the factory in config.ts. That's it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Command } from "commander";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Shared result types (returned by all backends in a uniform shape)
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A single memory item returned by searchGroup().
|
|
20
|
+
* Backends are responsible for mapping their native result shape to this.
|
|
21
|
+
*/
|
|
22
|
+
export type SearchResult = {
|
|
23
|
+
/** "node"/"fact" for graph backends; "chunk"/"summary"/"completion" for doc backends */
|
|
24
|
+
type: "node" | "fact" | "chunk" | "summary" | "completion";
|
|
25
|
+
/** Stable ID used by memory_forget. Must be unique within the backend. */
|
|
26
|
+
uuid: string;
|
|
27
|
+
group_id: string;
|
|
28
|
+
summary: string;
|
|
29
|
+
/** Human-readable context hint (entity names, dataset, etc.) */
|
|
30
|
+
context: string;
|
|
31
|
+
created_at: string;
|
|
32
|
+
/** Relevance score [0,1] when available */
|
|
33
|
+
score?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returned by store(). The fragmentId resolves to the UUID that will be
|
|
38
|
+
* registered in SpiceDB.
|
|
39
|
+
*
|
|
40
|
+
* - Graphiti: Resolves once the server has processed the episode (polled in the background).
|
|
41
|
+
*
|
|
42
|
+
* index.ts chains SpiceDB writeFragmentRelationships() to this Promise,
|
|
43
|
+
* so it always fires at the right time.
|
|
44
|
+
*/
|
|
45
|
+
export type StoreResult = {
|
|
46
|
+
fragmentId: Promise<string>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A single conversation turn, used for episodic recall.
|
|
51
|
+
* Backends that don't support conversation history return [].
|
|
52
|
+
*/
|
|
53
|
+
export type ConversationTurn = {
|
|
54
|
+
query: string;
|
|
55
|
+
answer: string;
|
|
56
|
+
context?: string;
|
|
57
|
+
created_at?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Minimal dataset descriptor for the CLI `datasets` command.
|
|
62
|
+
*/
|
|
63
|
+
export type BackendDataset = {
|
|
64
|
+
name: string;
|
|
65
|
+
/** Group ID this dataset maps to (backend-specific derivation) */
|
|
66
|
+
groupId: string;
|
|
67
|
+
/** Optional backend-specific dataset ID */
|
|
68
|
+
id?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// MemoryBackend interface
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
export interface MemoryBackend {
|
|
76
|
+
/** Human-readable backend name for logs and status output */
|
|
77
|
+
readonly name: string;
|
|
78
|
+
|
|
79
|
+
// --------------------------------------------------------------------------
|
|
80
|
+
// Core write
|
|
81
|
+
// Backends MUST return immediately — graph/index construction is async.
|
|
82
|
+
// --------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ingest content into the group's storage partition.
|
|
86
|
+
* The returned StoreResult.fragmentId resolves when the backend has
|
|
87
|
+
* produced a stable UUID suitable for SpiceDB registration.
|
|
88
|
+
*/
|
|
89
|
+
store(params: {
|
|
90
|
+
content: string;
|
|
91
|
+
groupId: string;
|
|
92
|
+
sourceDescription?: string;
|
|
93
|
+
customPrompt?: string;
|
|
94
|
+
}): Promise<StoreResult>;
|
|
95
|
+
|
|
96
|
+
// --------------------------------------------------------------------------
|
|
97
|
+
// Core read
|
|
98
|
+
// --------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Search within a single group's storage partition.
|
|
102
|
+
* Called in parallel per-group by searchAuthorizedMemories() in search.ts.
|
|
103
|
+
* Backends map their native result shape to SearchResult[].
|
|
104
|
+
*/
|
|
105
|
+
searchGroup(params: {
|
|
106
|
+
query: string;
|
|
107
|
+
groupId: string;
|
|
108
|
+
limit: number;
|
|
109
|
+
sessionId?: string;
|
|
110
|
+
}): Promise<SearchResult[]>;
|
|
111
|
+
|
|
112
|
+
// --------------------------------------------------------------------------
|
|
113
|
+
// Session / episodic memory
|
|
114
|
+
// --------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Optional backend-specific session enrichment, called from agent_end
|
|
118
|
+
* after store() for conversation auto-capture.
|
|
119
|
+
*
|
|
120
|
+
* Graphiti: no-op (addEpisode already handles episodic memory).
|
|
121
|
+
*/
|
|
122
|
+
enrichSession?(params: {
|
|
123
|
+
sessionId: string;
|
|
124
|
+
groupId: string;
|
|
125
|
+
userMsg: string;
|
|
126
|
+
assistantMsg: string;
|
|
127
|
+
}): Promise<void>;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Retrieve conversation history for a session.
|
|
131
|
+
* Graphiti maps getEpisodes() to this shape.
|
|
132
|
+
* Backends that don't support it return [].
|
|
133
|
+
*/
|
|
134
|
+
getConversationHistory(sessionId: string, lastN?: number): Promise<ConversationTurn[]>;
|
|
135
|
+
|
|
136
|
+
// --------------------------------------------------------------------------
|
|
137
|
+
// Lifecycle
|
|
138
|
+
// --------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
healthCheck(): Promise<boolean>;
|
|
141
|
+
getStatus(): Promise<Record<string, unknown>>;
|
|
142
|
+
|
|
143
|
+
// --------------------------------------------------------------------------
|
|
144
|
+
// Management
|
|
145
|
+
// --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Delete a group's entire storage partition.
|
|
149
|
+
* Used by `rebac-mem clear-group --confirm`.
|
|
150
|
+
*/
|
|
151
|
+
deleteGroup(groupId: string): Promise<void>;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* List all storage partitions (datasets/groups) managed by this backend.
|
|
155
|
+
*/
|
|
156
|
+
listGroups(): Promise<BackendDataset[]>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delete a single memory fragment by UUID.
|
|
160
|
+
* Optional: not all backends support sub-dataset deletion.
|
|
161
|
+
* Returns true if deleted, false if the backend doesn't support it.
|
|
162
|
+
*/
|
|
163
|
+
deleteFragment?(uuid: string): Promise<boolean>;
|
|
164
|
+
|
|
165
|
+
// --------------------------------------------------------------------------
|
|
166
|
+
// CLI extension point
|
|
167
|
+
// --------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Register backend-specific CLI subcommands onto the shared `rebac-mem` command.
|
|
171
|
+
* Called once during CLI setup. Backend may register any commands it needs.
|
|
172
|
+
*
|
|
173
|
+
* Example: Graphiti registers episodes, fact, clear-graph
|
|
174
|
+
*/
|
|
175
|
+
registerCliCommands?(cmd: Command): void;
|
|
176
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("node:crypto", async (importOriginal) => {
|
|
4
|
+
const mod = await importOriginal<typeof import("node:crypto")>();
|
|
5
|
+
return { ...mod, randomUUID: vi.fn(() => "test-uuid-000") };
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
import { GraphitiBackend } from "./graphiti.js";
|
|
9
|
+
import type { GraphitiConfig } from "./graphiti.js";
|
|
10
|
+
|
|
11
|
+
const defaultConfig: GraphitiConfig = {
|
|
12
|
+
endpoint: "http://localhost:8000",
|
|
13
|
+
defaultGroupId: "main",
|
|
14
|
+
uuidPollIntervalMs: 100,
|
|
15
|
+
uuidPollMaxAttempts: 5,
|
|
16
|
+
requestTimeoutMs: 5000,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Mock global fetch
|
|
20
|
+
const mockFetch = vi.fn();
|
|
21
|
+
global.fetch = mockFetch as unknown as typeof fetch;
|
|
22
|
+
|
|
23
|
+
/** Helper to create a mock JSON response */
|
|
24
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
25
|
+
return {
|
|
26
|
+
ok: status >= 200 && status < 300,
|
|
27
|
+
status,
|
|
28
|
+
headers: new Map([["content-type", "application/json"]]),
|
|
29
|
+
json: vi.fn().mockResolvedValue(body),
|
|
30
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("GraphitiBackend", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
mockFetch.mockReset();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("has correct name", () => {
|
|
44
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
45
|
+
expect(backend.name).toBe("graphiti");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("store", () => {
|
|
49
|
+
test("returns StoreResult with fragmentId Promise", async () => {
|
|
50
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
51
|
+
|
|
52
|
+
// Mock POST /messages (202 Accepted)
|
|
53
|
+
mockFetch.mockResolvedValueOnce(
|
|
54
|
+
jsonResponse({ message: "Messages accepted", success: true }, 202),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Mock GET /episodes polling (return episode on first poll)
|
|
58
|
+
mockFetch.mockResolvedValueOnce(
|
|
59
|
+
jsonResponse([
|
|
60
|
+
{ uuid: "real-uuid-123", name: "memory_test-uuid-000", group_id: "main", created_at: "2026-03-01" },
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const result = await backend.store({
|
|
65
|
+
content: "Test memory",
|
|
66
|
+
groupId: "main",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.fragmentId).toBeInstanceOf(Promise);
|
|
70
|
+
const uuid = await result.fragmentId;
|
|
71
|
+
expect(uuid).toBe("real-uuid-123");
|
|
72
|
+
|
|
73
|
+
// Verify POST /messages was called
|
|
74
|
+
const storeCall = mockFetch.mock.calls[0];
|
|
75
|
+
expect(storeCall[0]).toBe("http://localhost:8000/messages");
|
|
76
|
+
const body = JSON.parse(storeCall[1].body as string);
|
|
77
|
+
expect(body.group_id).toBe("main");
|
|
78
|
+
expect(body.messages).toHaveLength(1);
|
|
79
|
+
expect(body.messages[0].content).toBe("Test memory");
|
|
80
|
+
expect(body.messages[0].uuid).toBeUndefined();
|
|
81
|
+
expect(body.messages[0].name).toBe("memory_test-uuid-000");
|
|
82
|
+
expect(body.messages[0].timestamp).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("passes customPrompt in message content with instructions wrapper", async () => {
|
|
86
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
87
|
+
|
|
88
|
+
// Mock POST /messages
|
|
89
|
+
mockFetch.mockResolvedValueOnce(
|
|
90
|
+
jsonResponse({ message: "Accepted", success: true }, 202),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await backend.store({
|
|
94
|
+
content: "Important fact",
|
|
95
|
+
groupId: "main",
|
|
96
|
+
customPrompt: "Extract only names",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const storeCall = mockFetch.mock.calls[0];
|
|
100
|
+
const body = JSON.parse(storeCall[1].body as string);
|
|
101
|
+
expect(body.messages[0].content).toContain("[Extraction Instructions]");
|
|
102
|
+
expect(body.messages[0].content).toContain("Extract only names");
|
|
103
|
+
expect(body.messages[0].content).toContain("[End Instructions]");
|
|
104
|
+
expect(body.messages[0].content).toContain("Important fact");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("searchGroup", () => {
|
|
109
|
+
test("calls POST /search and returns facts as SearchResult[]", async () => {
|
|
110
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
111
|
+
|
|
112
|
+
mockFetch.mockResolvedValueOnce(
|
|
113
|
+
jsonResponse({
|
|
114
|
+
facts: [
|
|
115
|
+
{
|
|
116
|
+
uuid: "f1",
|
|
117
|
+
name: "WORKS_AT",
|
|
118
|
+
fact: "Mark works at Acme",
|
|
119
|
+
valid_at: null,
|
|
120
|
+
invalid_at: null,
|
|
121
|
+
created_at: "2026-01-16",
|
|
122
|
+
expired_at: null,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const results = await backend.searchGroup({
|
|
129
|
+
query: "Mark work",
|
|
130
|
+
groupId: "g1",
|
|
131
|
+
limit: 10,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(results).toHaveLength(1);
|
|
135
|
+
expect(results[0]).toMatchObject({
|
|
136
|
+
type: "fact",
|
|
137
|
+
uuid: "f1",
|
|
138
|
+
group_id: "g1",
|
|
139
|
+
summary: "Mark works at Acme",
|
|
140
|
+
context: "WORKS_AT",
|
|
141
|
+
created_at: "2026-01-16",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Verify POST /search was called with correct body
|
|
145
|
+
const searchCall = mockFetch.mock.calls[0];
|
|
146
|
+
expect(searchCall[0]).toBe("http://localhost:8000/search");
|
|
147
|
+
const body = JSON.parse(searchCall[1].body as string);
|
|
148
|
+
expect(body.group_ids).toEqual(["g1"]);
|
|
149
|
+
expect(body.query).toBe("Mark work");
|
|
150
|
+
expect(body.max_facts).toBe(10);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns empty array when no facts found", async () => {
|
|
154
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
155
|
+
|
|
156
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ facts: [] }));
|
|
157
|
+
|
|
158
|
+
const results = await backend.searchGroup({
|
|
159
|
+
query: "nothing",
|
|
160
|
+
groupId: "g1",
|
|
161
|
+
limit: 10,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(results).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("healthCheck", () => {
|
|
169
|
+
test("returns true when /healthcheck responds ok", async () => {
|
|
170
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
171
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
172
|
+
|
|
173
|
+
const healthy = await backend.healthCheck();
|
|
174
|
+
expect(healthy).toBe(true);
|
|
175
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
176
|
+
"http://localhost:8000/healthcheck",
|
|
177
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("returns false when /healthcheck fails", async () => {
|
|
182
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
183
|
+
mockFetch.mockResolvedValueOnce({ ok: false });
|
|
184
|
+
|
|
185
|
+
const healthy = await backend.healthCheck();
|
|
186
|
+
expect(healthy).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("returns false when /healthcheck throws", async () => {
|
|
190
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
191
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
192
|
+
|
|
193
|
+
const healthy = await backend.healthCheck();
|
|
194
|
+
expect(healthy).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("getStatus", () => {
|
|
199
|
+
test("returns status object with backend name and healthy flag", async () => {
|
|
200
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
201
|
+
mockFetch.mockResolvedValueOnce({ ok: true });
|
|
202
|
+
|
|
203
|
+
const status = await backend.getStatus();
|
|
204
|
+
expect(status.backend).toBe("graphiti");
|
|
205
|
+
expect(status.endpoint).toBe("http://localhost:8000");
|
|
206
|
+
expect(status.healthy).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("reports unhealthy when health check fails", async () => {
|
|
210
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
211
|
+
mockFetch.mockRejectedValueOnce(new Error("Down"));
|
|
212
|
+
|
|
213
|
+
const status = await backend.getStatus();
|
|
214
|
+
expect(status.healthy).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("deleteFragment", () => {
|
|
219
|
+
test("calls DELETE /episode/{uuid}", async () => {
|
|
220
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
221
|
+
|
|
222
|
+
mockFetch.mockResolvedValueOnce(
|
|
223
|
+
jsonResponse({ message: "Deleted", success: true }),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const result = await backend.deleteFragment?.("episode-uuid-123");
|
|
227
|
+
expect(result).toBe(true);
|
|
228
|
+
|
|
229
|
+
const deleteCall = mockFetch.mock.calls[0];
|
|
230
|
+
expect(deleteCall[0]).toBe("http://localhost:8000/episode/episode-uuid-123");
|
|
231
|
+
expect(deleteCall[1].method).toBe("DELETE");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("deleteGroup", () => {
|
|
236
|
+
test("calls DELETE /group/{groupId}", async () => {
|
|
237
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
238
|
+
|
|
239
|
+
mockFetch.mockResolvedValueOnce(
|
|
240
|
+
jsonResponse({ message: "Cleared", success: true }),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await backend.deleteGroup("old-group");
|
|
244
|
+
|
|
245
|
+
const deleteCall = mockFetch.mock.calls[0];
|
|
246
|
+
expect(deleteCall[0]).toBe("http://localhost:8000/group/old-group");
|
|
247
|
+
expect(deleteCall[1].method).toBe("DELETE");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("listGroups", () => {
|
|
252
|
+
test("returns empty array (not implemented)", async () => {
|
|
253
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
254
|
+
const groups = await backend.listGroups();
|
|
255
|
+
expect(groups).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("getConversationHistory", () => {
|
|
260
|
+
test("maps getEpisodes to ConversationTurn[]", async () => {
|
|
261
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
262
|
+
|
|
263
|
+
mockFetch.mockResolvedValueOnce(
|
|
264
|
+
jsonResponse([
|
|
265
|
+
{ uuid: "e1", name: "Turn 1", content: "User: Hi\nAssistant: Hello", created_at: "2026-01-15" },
|
|
266
|
+
{ uuid: "e2", name: "Turn 2", content: "User: Bye\nAssistant: Goodbye", created_at: "2026-01-16" },
|
|
267
|
+
]),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const turns = await backend.getConversationHistory("session-123", 10);
|
|
271
|
+
expect(turns).toHaveLength(2);
|
|
272
|
+
expect(turns[0]).toMatchObject({
|
|
273
|
+
query: "Turn 1",
|
|
274
|
+
answer: "User: Hi\nAssistant: Hello",
|
|
275
|
+
created_at: "2026-01-15",
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Verify GET /episodes/session-session-123 was called
|
|
279
|
+
const episodesCall = mockFetch.mock.calls[0];
|
|
280
|
+
expect(episodesCall[0]).toBe("http://localhost:8000/episodes/session-session-123?last_n=10");
|
|
281
|
+
expect(episodesCall[1].method).toBe("GET");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("returns empty array on error", async () => {
|
|
285
|
+
const backend = new GraphitiBackend(defaultConfig);
|
|
286
|
+
mockFetch.mockRejectedValueOnce(new Error("Fail"));
|
|
287
|
+
|
|
288
|
+
const turns = await backend.getConversationHistory("session-123");
|
|
289
|
+
expect(turns).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|