232 lines
8.7 KiB
TypeScript
232 lines
8.7 KiB
TypeScript
import { vi, describe, it, expect, afterEach } from 'vitest';
|
|
|
|
vi.mock('../../src/config.js', () => ({
|
|
config: {
|
|
GRAPHMCP_URL: 'http://localhost:9000',
|
|
GRAPHMCP_SCORE_THRESHOLD: 0.68,
|
|
},
|
|
}));
|
|
|
|
import { formatNPCMemory, semanticSearch, listEncounters, queryAsNPC } from '../../src/graphmcp/client.js';
|
|
import type { NPCQueryResult } from '../../src/graphmcp/client.js';
|
|
|
|
const emptyResult: NPCQueryResult = {
|
|
npc: 'Miriam',
|
|
tier: 'local',
|
|
horizon_count: 0,
|
|
chunks: [],
|
|
graph_context: [],
|
|
};
|
|
|
|
describe('formatNPCMemory', () => {
|
|
it('returns first-meeting text for null input', () => {
|
|
expect(formatNPCMemory(null)).toBe('No prior encounters on record — first meeting.');
|
|
});
|
|
|
|
it('returns first-meeting text for empty result', () => {
|
|
expect(formatNPCMemory(emptyResult)).toBe('No prior encounters on record — first meeting.');
|
|
});
|
|
|
|
it('formats graph_context encounters with date and title', () => {
|
|
const result: NPCQueryResult = {
|
|
...emptyResult,
|
|
horizon_count: 1,
|
|
graph_context: [{
|
|
enc_id: 'e1',
|
|
enc_title: 'Market Thief',
|
|
enc_type: 'encounter',
|
|
enc_timestamp: '2026-05-24T00:00:00Z',
|
|
enc_summary: 'A thief stole an apple.',
|
|
featured_entities: [],
|
|
locations: [],
|
|
}],
|
|
};
|
|
const output = formatNPCMemory(result);
|
|
expect(output).toContain('Market Thief');
|
|
expect(output).toContain('A thief stole an apple.');
|
|
expect(output).toContain('2026-05-24');
|
|
});
|
|
|
|
it('includes chunks above the score threshold', () => {
|
|
const result: NPCQueryResult = {
|
|
...emptyResult,
|
|
horizon_count: 1,
|
|
chunks: [{ text: 'Relevant lore about Miriam.', score: 0.85, source: 'lore', author: '', timestamp: '' }],
|
|
};
|
|
const output = formatNPCMemory(result);
|
|
expect(output).toContain('Relevant lore about Miriam.');
|
|
});
|
|
|
|
it('excludes chunks below the score threshold', () => {
|
|
const result: NPCQueryResult = {
|
|
...emptyResult,
|
|
horizon_count: 1,
|
|
chunks: [{ text: 'Low-confidence chunk.', score: 0.5, source: 'lore', author: '', timestamp: '' }],
|
|
};
|
|
const output = formatNPCMemory(result);
|
|
expect(output).not.toContain('Low-confidence chunk.');
|
|
});
|
|
|
|
it('truncates chunks longer than 200 characters', () => {
|
|
const longText = 'A'.repeat(300);
|
|
const result: NPCQueryResult = {
|
|
...emptyResult,
|
|
horizon_count: 1,
|
|
chunks: [{ text: longText, score: 0.9, source: 'lore', author: '', timestamp: '' }],
|
|
};
|
|
const output = formatNPCMemory(result);
|
|
expect(output).toContain('…');
|
|
expect(output).not.toContain('A'.repeat(300));
|
|
});
|
|
|
|
it('includes at most 3 chunks', () => {
|
|
const chunks = Array.from({ length: 5 }, (_, i) => ({
|
|
text: `Chunk ${i} content here.`,
|
|
score: 0.9,
|
|
source: 'lore' as const,
|
|
author: '',
|
|
timestamp: '',
|
|
}));
|
|
const result: NPCQueryResult = { ...emptyResult, horizon_count: 5, chunks };
|
|
const output = formatNPCMemory(result);
|
|
const matchCount = (output.match(/Chunk \d content here\./g) ?? []).length;
|
|
expect(matchCount).toBeLessThanOrEqual(3);
|
|
});
|
|
});
|
|
|
|
// Build a GraphMCP JSON-RPC envelope whose tool-result text is JSON.stringify(payload).
|
|
// callTool parses json.result.content[0].text, so this lets us feed arbitrary
|
|
// tool-result shapes to the public functions.
|
|
function rpcEnvelope(payload: unknown): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
jsonrpc: '2.0',
|
|
result: { content: [{ type: 'text', text: JSON.stringify(payload) }] },
|
|
}),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
describe('semanticSearch response normalization', () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
// Regression: /encounter generate crashed with "Cannot read properties of
|
|
// undefined (reading 'length')" when GraphMCP returned a success response
|
|
// whose `chunks` field was missing/null. The `.catch(() => ({ chunks: [] }))`
|
|
// at the call site only covers rejection, not a wrong-shape success.
|
|
it('returns [] when chunks is null (no crash on .length)', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({ chunks: null })));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks).toEqual([]);
|
|
});
|
|
|
|
it('returns [] when the response has no chunks field', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({ results: [{ content: 'x' }] })));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks).toEqual([]);
|
|
});
|
|
|
|
it('returns [] when GraphMCP returns null', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope(null)));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks).toEqual([]);
|
|
});
|
|
|
|
it('accepts a bare array as the chunks', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{ content: 'a', score: 1 }])));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks).toHaveLength(1);
|
|
expect(result.chunks[0].content).toBe('a');
|
|
});
|
|
|
|
it('preserves a well-formed { chunks: [...] } response', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({
|
|
chunks: [{ content: 'a', score: 0.9 }, { content: 'b', score: 0.8 }],
|
|
})));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('listEncounters response normalization', () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it('returns [] for a non-array response instead of leaking the wrong shape', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({ encounters: [{ id: '1' }] })));
|
|
const result = await listEncounters(5);
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('returns the array when GraphMCP returns one', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{
|
|
id: '1', title: 't', location: 'l', timestamp: '', summary: 's',
|
|
}])));
|
|
const result = await listEncounters(5);
|
|
expect(result).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// Regression: the live GraphMCP backend returns chunks shaped as
|
|
// { text, score, source, author, timestamp, msgID } — NOT { content, ... }.
|
|
// The client's SemanticChunk type and its callers (encounter.ts handleGenerate
|
|
// does `c.content.slice(...)`, mentionHandler reads `c.content`) expect
|
|
// `.content`. Without boundary mapping, `.content` is undefined and
|
|
// `c.content.slice` throws the same "Cannot read properties of undefined"
|
|
// class as the loreResult.chunks crash. semanticSearch must map text→content.
|
|
describe('semanticSearch chunk field mapping (live shape: text, not content)', () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it('maps the live `text` field to the declared `content` field', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{
|
|
text: 'tell me about Mardonar',
|
|
score: 0.84,
|
|
source: 'message',
|
|
author: 'sirhaxolot',
|
|
timestamp: '2026-05-26T03:06:18Z',
|
|
msgID: '1508667570604081356',
|
|
}])));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks).toHaveLength(1);
|
|
expect(result.chunks[0].content).toBe('tell me about Mardonar');
|
|
expect(result.chunks[0].score).toBe(0.84);
|
|
expect(result.chunks[0].source).toBe('message');
|
|
});
|
|
|
|
it('falls back to `content` when a chunk uses the declared field name', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{ content: 'legacy', score: 0.5 }])));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks[0].content).toBe('legacy');
|
|
});
|
|
|
|
it('coerces a chunk missing both text and content to an empty string (no crash)', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope([{ score: 0.5 }])));
|
|
const result = await semanticSearch('q', 5);
|
|
expect(result.chunks[0].content).toBe('');
|
|
expect(result.chunks[0].score).toBe(0.5);
|
|
});
|
|
});
|
|
|
|
// Regression: the live GraphMCP backend returns `chunks: null` (and sometimes
|
|
// `graph_context: null`) for NPCs with no prior memory. The raw
|
|
// `as NPCQueryResult` cast let null leak through; the contract is arrays.
|
|
describe('queryAsNPC null-array normalization', () => {
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it('coerces null chunks and graph_context to empty arrays', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => rpcEnvelope({
|
|
npc: 'miriam-merchant-mardonar',
|
|
tier: 'local',
|
|
horizon_count: 0,
|
|
chunks: null,
|
|
graph_context: null,
|
|
})));
|
|
const result = await queryAsNPC('miriam-merchant-mardonar', 'recent events', 5);
|
|
expect(Array.isArray(result.chunks)).toBe(true);
|
|
expect(result.chunks).toEqual([]);
|
|
expect(Array.isArray(result.graph_context)).toBe(true);
|
|
expect(result.npc).toBe('miriam-merchant-mardonar');
|
|
expect(result.horizon_count).toBe(0);
|
|
});
|
|
});
|