Skip to content

Commit 10a8f12

Browse files
author
StackMemory Bot (CLI)
committed
feat(graphiti): add tests and expose MCP tools
Add 27 tests for GraphitiClient (15) and GraphitiHooks (12) covering all methods, retry logic, timeout, error handling, and event wiring. Expose graphiti_status and graphiti_query as MCP tools, gated behind GRAPHITI_ENDPOINT env var, following the same spread pattern as provider tools.
1 parent cfe15b6 commit 10a8f12

4 files changed

Lines changed: 742 additions & 1 deletion

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { GraphitiHooks } from '../graphiti-hooks.js';
3+
import { HookEventEmitter } from '../events.js';
4+
import type { HookEvent, FileChangeEvent } from '../events.js';
5+
6+
// Mock logger to suppress output
7+
vi.mock('../../core/monitoring/logger.js', () => ({
8+
logger: {
9+
info: vi.fn(),
10+
debug: vi.fn(),
11+
warn: vi.fn(),
12+
error: vi.fn(),
13+
},
14+
}));
15+
16+
describe('GraphitiHooks', () => {
17+
let emitter: HookEventEmitter;
18+
19+
beforeEach(() => {
20+
emitter = new HookEventEmitter();
21+
});
22+
23+
afterEach(() => {
24+
emitter.removeAllListeners();
25+
vi.restoreAllMocks();
26+
});
27+
28+
function makeHooks(overrides = {}) {
29+
return new GraphitiHooks({
30+
enabled: true,
31+
endpoint: 'http://localhost:9999',
32+
maxRetries: 0,
33+
timeoutMs: 1000,
34+
projectNamespace: 'test-ns',
35+
...overrides,
36+
});
37+
}
38+
39+
function mockClient(hooks: GraphitiHooks) {
40+
const client = {
41+
getStatus: vi.fn(),
42+
upsertEpisode: vi.fn(),
43+
upsertEntities: vi.fn(),
44+
upsertRelations: vi.fn(),
45+
queryTemporal: vi.fn(),
46+
};
47+
(hooks as any).client = client;
48+
return client;
49+
}
50+
51+
// ── register ──
52+
53+
describe('register', () => {
54+
it('registers handlers for session_start, file_change, session_end', () => {
55+
const hooks = makeHooks();
56+
hooks.register(emitter);
57+
58+
const events = emitter.getRegisteredEvents();
59+
expect(events).toContain('session_start');
60+
expect(events).toContain('file_change');
61+
expect(events).toContain('session_end');
62+
});
63+
64+
it('skips registration when enabled=false', () => {
65+
const hooks = makeHooks({ enabled: false });
66+
hooks.register(emitter);
67+
68+
const events = emitter.getRegisteredEvents();
69+
expect(events).toHaveLength(0);
70+
});
71+
});
72+
73+
// ── onSessionStart ──
74+
75+
describe('onSessionStart', () => {
76+
it('checks status and upserts episode when connected', async () => {
77+
const hooks = makeHooks();
78+
const client = mockClient(hooks);
79+
client.getStatus.mockResolvedValue({ connected: true });
80+
client.upsertEpisode.mockResolvedValue({ id: 'ep-1' });
81+
hooks.register(emitter);
82+
83+
const event: HookEvent = {
84+
type: 'session_start',
85+
timestamp: Date.now(),
86+
data: { sessionId: 'sess-1' },
87+
};
88+
await emitter.emitHook(event);
89+
90+
expect(client.getStatus).toHaveBeenCalledOnce();
91+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
92+
const episode = client.upsertEpisode.mock.calls[0][0];
93+
expect(episode.type).toBe('session_start');
94+
expect(episode.source).toBe('stackmemory');
95+
});
96+
97+
it('skips upsert when Graphiti is disconnected', async () => {
98+
const hooks = makeHooks();
99+
const client = mockClient(hooks);
100+
client.getStatus.mockResolvedValue({ connected: false });
101+
hooks.register(emitter);
102+
103+
await emitter.emitHook({
104+
type: 'session_start',
105+
timestamp: Date.now(),
106+
data: {},
107+
});
108+
109+
expect(client.getStatus).toHaveBeenCalledOnce();
110+
expect(client.upsertEpisode).not.toHaveBeenCalled();
111+
});
112+
});
113+
114+
// ── onFileChange ──
115+
116+
describe('onFileChange', () => {
117+
it('maps FileChangeEvent to Episode correctly', async () => {
118+
const hooks = makeHooks();
119+
const client = mockClient(hooks);
120+
client.upsertEpisode.mockResolvedValue({ id: 'ep-2' });
121+
hooks.register(emitter);
122+
123+
const event: FileChangeEvent = {
124+
type: 'file_change',
125+
timestamp: Date.now(),
126+
data: {
127+
path: '/src/index.ts',
128+
changeType: 'modify',
129+
content: 'hello world',
130+
},
131+
};
132+
await emitter.emitHook(event);
133+
134+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
135+
const episode = client.upsertEpisode.mock.calls[0][0];
136+
expect(episode.type).toBe('file_change');
137+
expect(episode.content).toEqual({
138+
path: '/src/index.ts',
139+
changeType: 'modify',
140+
size: 11,
141+
});
142+
});
143+
144+
it('handles missing content gracefully', async () => {
145+
const hooks = makeHooks();
146+
const client = mockClient(hooks);
147+
client.upsertEpisode.mockResolvedValue({ id: 'ep-3' });
148+
hooks.register(emitter);
149+
150+
const event: FileChangeEvent = {
151+
type: 'file_change',
152+
timestamp: Date.now(),
153+
data: {
154+
path: '/src/deleted.ts',
155+
changeType: 'delete',
156+
},
157+
};
158+
await emitter.emitHook(event);
159+
160+
const episode = client.upsertEpisode.mock.calls[0][0];
161+
expect(episode.content.size).toBeUndefined();
162+
});
163+
});
164+
165+
// ── onSessionEnd ──
166+
167+
describe('onSessionEnd', () => {
168+
it('upserts session_end episode', async () => {
169+
const hooks = makeHooks();
170+
const client = mockClient(hooks);
171+
client.upsertEpisode.mockResolvedValue({ id: 'ep-4' });
172+
hooks.register(emitter);
173+
174+
await emitter.emitHook({
175+
type: 'session_end',
176+
timestamp: Date.now(),
177+
data: { reason: 'user_quit' },
178+
});
179+
180+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
181+
const episode = client.upsertEpisode.mock.calls[0][0];
182+
expect(episode.type).toBe('session_end');
183+
expect(episode.source).toBe('stackmemory');
184+
});
185+
});
186+
187+
// ── Error resilience ──
188+
189+
describe('error resilience', () => {
190+
it('catches handler errors without propagating', async () => {
191+
const hooks = makeHooks();
192+
const client = mockClient(hooks);
193+
client.getStatus.mockRejectedValue(new Error('boom'));
194+
hooks.register(emitter);
195+
196+
// Should not throw
197+
await emitter.emitHook({
198+
type: 'session_start',
199+
timestamp: Date.now(),
200+
data: {},
201+
});
202+
203+
expect(client.getStatus).toHaveBeenCalledOnce();
204+
});
205+
206+
it('catches file_change errors without propagating', async () => {
207+
const hooks = makeHooks();
208+
const client = mockClient(hooks);
209+
client.upsertEpisode.mockRejectedValue(new Error('write fail'));
210+
hooks.register(emitter);
211+
212+
await emitter.emitHook({
213+
type: 'file_change',
214+
timestamp: Date.now(),
215+
data: { path: '/x.ts', changeType: 'create' },
216+
} as FileChangeEvent);
217+
218+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
219+
});
220+
});
221+
222+
// ── buildTemporalContext ──
223+
224+
describe('buildTemporalContext', () => {
225+
it('passes query with defaults to queryTemporal', async () => {
226+
const hooks = makeHooks();
227+
const client = mockClient(hooks);
228+
const ctx = { chunks: [{ text: 'result' }], totalTokens: 10 };
229+
client.queryTemporal.mockResolvedValue(ctx);
230+
231+
const result = await hooks.buildTemporalContext({ query: 'find X' });
232+
233+
expect(result).toEqual(ctx);
234+
expect(client.queryTemporal).toHaveBeenCalledOnce();
235+
const q = client.queryTemporal.mock.calls[0][0];
236+
expect(q.query).toBe('find X');
237+
expect(q.k).toBe(20);
238+
expect(q.rerank).toBe(true);
239+
expect(q.maxHops).toBe(2);
240+
expect(q.validFrom).toBeDefined();
241+
expect(q.validTo).toBeDefined();
242+
});
243+
244+
it('uses defaults when no query provided', async () => {
245+
const hooks = makeHooks();
246+
const client = mockClient(hooks);
247+
client.queryTemporal.mockResolvedValue({ chunks: [], totalTokens: 0 });
248+
249+
await hooks.buildTemporalContext();
250+
251+
const q = client.queryTemporal.mock.calls[0][0];
252+
expect(q.query).toBeUndefined();
253+
expect(q.entityTypes).toBeUndefined();
254+
expect(q.k).toBe(20);
255+
});
256+
257+
it('respects overridden maxHops from config', async () => {
258+
const hooks = makeHooks({ maxHops: 5 });
259+
const client = mockClient(hooks);
260+
client.queryTemporal.mockResolvedValue({ chunks: [], totalTokens: 0 });
261+
262+
await hooks.buildTemporalContext();
263+
264+
const q = client.queryTemporal.mock.calls[0][0];
265+
expect(q.maxHops).toBe(5);
266+
});
267+
});
268+
});

0 commit comments

Comments
 (0)