servers/src/memory/__tests__/knowledge-graph.test.ts
Yiheng Zhao c3d8e43d1f
fix(memory): validate knowledge graph entries when loading from disk (#4717)
loadGraph() trusted the persisted memory file and pushed entities and
relations without validating their fields. A corrupted or legacy entry
(e.g. an entity missing entityType, or an observation that is not a string)
would reach searchNodes and crash with "Cannot read properties of undefined
(reading 'toLowerCase')".

Validate each line against the existing EntitySchema/RelationSchema and skip
malformed entries with a warning, so the in-memory graph only ever contains
well-formed data. Malformed JSON lines are skipped as well.

Fixes #2044
2026-09-02 18:34:58 -07:00

667 lines
25 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { KnowledgeGraphManager, Entity, Relation, KnowledgeGraph } from '../index.js';
describe('KnowledgeGraphManager', () => {
let manager: KnowledgeGraphManager;
let testFilePath: string;
beforeEach(async () => {
// Create a temporary test file path
testFilePath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
`test-memory-${Date.now()}.jsonl`
);
manager = new KnowledgeGraphManager(testFilePath);
});
afterEach(async () => {
// Clean up test file
try {
await fs.unlink(testFilePath);
} catch (error) {
// Ignore errors if file doesn't exist
}
});
describe('createEntities', () => {
it('should create new entities', async () => {
const entities: Entity[] = [
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
{ name: 'Bob', entityType: 'person', observations: ['likes programming'] },
];
const newEntities = await manager.createEntities(entities);
expect(newEntities).toHaveLength(2);
expect(newEntities).toEqual(entities);
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(2);
});
it('should not create duplicate entities', async () => {
const entities: Entity[] = [
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
];
await manager.createEntities(entities);
const newEntities = await manager.createEntities(entities);
expect(newEntities).toHaveLength(0);
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(1);
});
it('should handle empty entity arrays', async () => {
const newEntities = await manager.createEntities([]);
expect(newEntities).toHaveLength(0);
});
});
describe('createRelations', () => {
it('should create new relations', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
const relations: Relation[] = [
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
];
const newRelations = await manager.createRelations(relations);
expect(newRelations).toHaveLength(1);
expect(newRelations).toEqual(relations);
const graph = await manager.readGraph();
expect(graph.relations).toHaveLength(1);
});
it('should not create duplicate relations', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
const relations: Relation[] = [
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
];
await manager.createRelations(relations);
const newRelations = await manager.createRelations(relations);
expect(newRelations).toHaveLength(0);
const graph = await manager.readGraph();
expect(graph.relations).toHaveLength(1);
});
it('should reject relations from non-existent entities', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
]);
await expect(
manager.createRelations([
{ from: 'Ghost', to: 'Alice', relationType: 'knows' },
])
).rejects.toThrow('Entity with name Ghost not found');
const graph = await manager.readGraph();
expect(graph.relations).toHaveLength(0);
});
it('should reject relation batches that reference non-existent target entities', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
await expect(
manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
{ from: 'Alice', to: 'Ghost', relationType: 'knows' },
])
).rejects.toThrow('Entity with name Ghost not found');
const graph = await manager.readGraph();
expect(graph.relations).toHaveLength(0);
});
it('should handle empty relation arrays', async () => {
const newRelations = await manager.createRelations([]);
expect(newRelations).toHaveLength(0);
});
});
describe('addObservations', () => {
it('should add observations to existing entities', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
]);
const results = await manager.addObservations([
{ entityName: 'Alice', contents: ['likes coffee', 'has a dog'] },
]);
expect(results).toHaveLength(1);
expect(results[0].entityName).toBe('Alice');
expect(results[0].addedObservations).toHaveLength(2);
const graph = await manager.readGraph();
const alice = graph.entities.find(e => e.name === 'Alice');
expect(alice?.observations).toHaveLength(3);
});
it('should not add duplicate observations', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
]);
await manager.addObservations([
{ entityName: 'Alice', contents: ['likes coffee'] },
]);
const results = await manager.addObservations([
{ entityName: 'Alice', contents: ['likes coffee', 'has a dog'] },
]);
expect(results[0].addedObservations).toHaveLength(1);
expect(results[0].addedObservations).toContain('has a dog');
const graph = await manager.readGraph();
const alice = graph.entities.find(e => e.name === 'Alice');
expect(alice?.observations).toHaveLength(3);
});
it('should throw error for non-existent entity', async () => {
await expect(async () => {
await manager.addObservations([
{ entityName: 'NonExistent', contents: ['some observation'] },
]);
}).rejects.toThrow('Entity with name NonExistent not found');
});
});
describe('deleteEntities', () => {
it('should delete entities', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
await manager.deleteEntities(['Alice']);
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(1);
expect(graph.entities[0].name).toBe('Bob');
});
it('should cascade delete relations when deleting entities', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
{ name: 'Charlie', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
{ from: 'Bob', to: 'Charlie', relationType: 'knows' },
]);
await manager.deleteEntities(['Bob']);
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(2);
expect(graph.relations).toHaveLength(0);
});
it('should handle deleting non-existent entities', async () => {
await manager.deleteEntities(['NonExistent']);
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(0);
});
});
describe('deleteObservations', () => {
it('should delete observations from entities', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes coffee'] },
]);
await manager.deleteObservations([
{ entityName: 'Alice', observations: ['likes coffee'] },
]);
const graph = await manager.readGraph();
const alice = graph.entities.find(e => e.name === 'Alice');
expect(alice?.observations).toHaveLength(1);
expect(alice?.observations).toContain('works at Acme Corp');
});
it('should handle deleting from non-existent entities', async () => {
await manager.deleteObservations([
{ entityName: 'NonExistent', observations: ['some observation'] },
]);
// Should not throw error
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(0);
});
});
describe('deleteRelations', () => {
it('should delete specific relations', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
{ from: 'Alice', to: 'Bob', relationType: 'works_with' },
]);
await manager.deleteRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
]);
const graph = await manager.readGraph();
expect(graph.relations).toHaveLength(1);
expect(graph.relations[0].relationType).toBe('works_with');
});
});
describe('readGraph', () => {
it('should return empty graph when file does not exist', async () => {
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(0);
expect(graph.relations).toHaveLength(0);
});
it('should return complete graph with entities and relations', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Alice', relationType: 'self' },
]);
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(1);
expect(graph.relations).toHaveLength(1);
});
});
describe('searchNodes', () => {
beforeEach(async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['works at Acme Corp', 'likes programming'] },
{ name: 'Bob', entityType: 'person', observations: ['works at TechCo'] },
{ name: 'Acme Corp', entityType: 'company', observations: ['tech company'] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Acme Corp', relationType: 'works_at' },
{ from: 'Bob', to: 'Acme Corp', relationType: 'competitor' },
]);
});
it('should search by entity name', async () => {
const result = await manager.searchNodes('Alice');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].name).toBe('Alice');
});
it('should search by entity type', async () => {
const result = await manager.searchNodes('company');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].name).toBe('Acme Corp');
});
it('should search by observation content', async () => {
const result = await manager.searchNodes('programming');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].name).toBe('Alice');
});
it('should be case insensitive', async () => {
const result = await manager.searchNodes('ALICE');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].name).toBe('Alice');
});
it('should include relations where at least one endpoint matches', async () => {
const result = await manager.searchNodes('Acme');
expect(result.entities).toHaveLength(2); // Alice and Acme Corp
// Both relations included: Alice → Acme Corp (Alice matched) and Bob → Acme Corp (Acme Corp matched)
expect(result.relations).toHaveLength(2);
});
it('should include outgoing relations to unmatched entities', async () => {
const result = await manager.searchNodes('Alice');
expect(result.entities).toHaveLength(1);
// Alice → Acme Corp relation included because Alice is the source
expect(result.relations).toHaveLength(1);
expect(result.relations[0].from).toBe('Alice');
expect(result.relations[0].to).toBe('Acme Corp');
});
it('should return empty graph for no matches', async () => {
const result = await manager.searchNodes('NonExistent');
expect(result.entities).toHaveLength(0);
expect(result.relations).toHaveLength(0);
});
});
describe('openNodes', () => {
beforeEach(async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
{ name: 'Charlie', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
{ from: 'Bob', to: 'Charlie', relationType: 'knows' },
]);
});
it('should open specific nodes by name', async () => {
const result = await manager.openNodes(['Alice', 'Bob']);
expect(result.entities).toHaveLength(2);
expect(result.entities.map(e => e.name)).toContain('Alice');
expect(result.entities.map(e => e.name)).toContain('Bob');
});
it('should include all relations connected to opened nodes', async () => {
const result = await manager.openNodes(['Alice', 'Bob']);
// Alice → Bob (both endpoints opened) and Bob → Charlie (Bob is opened)
expect(result.relations).toHaveLength(2);
expect(result.relations.some(r => r.from === 'Alice' && r.to === 'Bob')).toBe(true);
expect(result.relations.some(r => r.from === 'Bob' && r.to === 'Charlie')).toBe(true);
});
it('should include relations connected to opened nodes', async () => {
const result = await manager.openNodes(['Bob']);
// Bob has two relations: Alice → Bob and Bob → Charlie
expect(result.relations).toHaveLength(2);
expect(result.relations.some(r => r.from === 'Alice' && r.to === 'Bob')).toBe(true);
expect(result.relations.some(r => r.from === 'Bob' && r.to === 'Charlie')).toBe(true);
});
it('should include outgoing relations to nodes not in the open set', async () => {
// This is the core bug fix for #3137: open_nodes should return
// relations FROM the opened node, even if the target is not opened
const result = await manager.openNodes(['Alice']);
expect(result.entities).toHaveLength(1);
expect(result.entities[0].name).toBe('Alice');
// Alice → Bob relation is included because Alice is opened
expect(result.relations).toHaveLength(1);
expect(result.relations[0].from).toBe('Alice');
expect(result.relations[0].to).toBe('Bob');
});
it('should include incoming relations from nodes not in the open set', async () => {
const result = await manager.openNodes(['Charlie']);
expect(result.entities).toHaveLength(1);
// Bob → Charlie relation is included because Charlie is opened
expect(result.relations).toHaveLength(1);
expect(result.relations[0].from).toBe('Bob');
expect(result.relations[0].to).toBe('Charlie');
});
it('should handle opening non-existent nodes', async () => {
const result = await manager.openNodes(['NonExistent']);
expect(result.entities).toHaveLength(0);
});
it('should handle empty node list', async () => {
const result = await manager.openNodes([]);
expect(result.entities).toHaveLength(0);
expect(result.relations).toHaveLength(0);
});
});
describe('file persistence', () => {
it('should persist data across manager instances', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['persistent data'] },
]);
// Create new manager instance with same file path
const manager2 = new KnowledgeGraphManager(testFilePath);
const graph = await manager2.readGraph();
expect(graph.entities).toHaveLength(1);
expect(graph.entities[0].name).toBe('Alice');
});
it('should handle JSONL format correctly', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Alice', relationType: 'self' },
]);
// Read file directly
const fileContent = await fs.readFile(testFilePath, 'utf-8');
const lines = fileContent.split('\n').filter(line => line.trim());
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0])).toHaveProperty('type', 'entity');
expect(JSON.parse(lines[1])).toHaveProperty('type', 'relation');
});
it('should write a trailing newline to produce valid JSONL', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['test'] },
]);
const fileContent = await fs.readFile(testFilePath, 'utf-8');
expect(fileContent.endsWith('\n')).toBe(true);
});
it('should produce a file where every line is individually valid JSON', async () => {
// This test catches the bug where saveGraph wrote lines.join("\n")
// without a trailing newline. When the file was later appended to
// (e.g. by a concurrent process or external tool), the last JSON
// object and the new first JSON object ended up on the same line,
// producing invalid JSONL like:
// {"type":"entity","name":"Alice"}{"type":"relation","from":"Alice",...}
// which fails with: "Unexpected non-whitespace character after JSON
// at position N"
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['test'] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
]);
const fileContent = await fs.readFile(testFilePath, 'utf-8');
const allLines = fileContent.split('\n');
// Every non-empty line must be valid JSON on its own
for (const line of allLines) {
if (line.trim() === '') continue;
expect(() => JSON.parse(line)).not.toThrow();
}
});
it('should not corrupt JSONL when content is appended to the file externally', async () => {
// Simulate the real-world corruption scenario:
// 1. saveGraph writes entities to the file
// 2. An external process appends a new JSON line to the file
// 3. loadGraph must still parse the file without errors
//
// Without a trailing newline on step 1, the appended content in
// step 2 lands on the same line as the last entity, producing
// invalid JSONL that breaks loadGraph.
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['original'] },
]);
// Simulate an external append (e.g. another process, a script, or
// a crash-recovery replay). This is what triggers the bug: without
// a trailing newline, this JSON object concatenates onto line 1.
const externalLine = JSON.stringify({
type: 'entity',
name: 'External',
entityType: 'person',
observations: ['appended externally'],
});
await fs.appendFile(testFilePath, externalLine + '\n');
// A new manager instance forces a fresh loadGraph from disk
const manager2 = new KnowledgeGraphManager(testFilePath);
const graph = await manager2.readGraph();
// Both entities must load without a JSON parse error
expect(graph.entities).toHaveLength(2);
expect(graph.entities.map(e => e.name)).toContain('Alice');
expect(graph.entities.map(e => e.name)).toContain('External');
});
it('should strip type field from entities when loading from file', async () => {
// Create entities and relations (these get saved with type field)
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['test observation'] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
]);
// Verify file contains type field (order may vary)
const fileContent = await fs.readFile(testFilePath, 'utf-8');
const fileLines = fileContent.split('\n').filter(line => line.trim());
const fileItems = fileLines.map(line => JSON.parse(line));
const fileEntity = fileItems.find(item => item.type === 'entity');
const fileRelation = fileItems.find(item => item.type === 'relation');
expect(fileEntity).toBeDefined();
expect(fileEntity).toHaveProperty('type', 'entity');
expect(fileRelation).toBeDefined();
expect(fileRelation).toHaveProperty('type', 'relation');
// Create new manager instance to force reload from file
const manager2 = new KnowledgeGraphManager(testFilePath);
const graph = await manager2.readGraph();
// Verify loaded entities don't have type field
expect(graph.entities).toHaveLength(2);
graph.entities.forEach(entity => {
expect(entity).not.toHaveProperty('type');
expect(entity).toHaveProperty('name');
expect(entity).toHaveProperty('entityType');
expect(entity).toHaveProperty('observations');
});
// Verify loaded relations don't have type field
expect(graph.relations).toHaveLength(1);
graph.relations.forEach(relation => {
expect(relation).not.toHaveProperty('type');
expect(relation).toHaveProperty('from');
expect(relation).toHaveProperty('to');
expect(relation).toHaveProperty('relationType');
});
});
it('should strip type field from searchNodes results', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: ['works at Acme'] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Alice', relationType: 'self' },
]);
// Create new manager instance to force reload from file
const manager2 = new KnowledgeGraphManager(testFilePath);
const result = await manager2.searchNodes('Alice');
// Verify search results don't have type field
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).not.toHaveProperty('type');
expect(result.entities[0].name).toBe('Alice');
expect(result.relations).toHaveLength(1);
expect(result.relations[0]).not.toHaveProperty('type');
expect(result.relations[0].from).toBe('Alice');
});
it('should strip type field from openNodes results', async () => {
await manager.createEntities([
{ name: 'Alice', entityType: 'person', observations: [] },
{ name: 'Bob', entityType: 'person', observations: [] },
]);
await manager.createRelations([
{ from: 'Alice', to: 'Bob', relationType: 'knows' },
]);
// Create new manager instance to force reload from file
const manager2 = new KnowledgeGraphManager(testFilePath);
const result = await manager2.openNodes(['Alice', 'Bob']);
// Verify open results don't have type field
expect(result.entities).toHaveLength(2);
result.entities.forEach(entity => {
expect(entity).not.toHaveProperty('type');
});
expect(result.relations).toHaveLength(1);
expect(result.relations[0]).not.toHaveProperty('type');
});
});
describe('loadGraph validation', () => {
it('skips corrupt entities instead of crashing search', async () => {
const lines = [
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: ['works at Acme Corp'] }),
JSON.stringify({ type: 'entity', name: 'Broken', observations: ['missing entityType'] }),
JSON.stringify({ type: 'entity', name: 'BadObs', entityType: 'person', observations: ['ok', null] }),
];
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(1);
expect(graph.entities[0].name).toBe('Alice');
// searchNodes must not throw even though the file contains corrupt entries
const result = await manager.searchNodes('Acme');
expect(result.entities).toHaveLength(1);
expect(result.entities[0].name).toBe('Alice');
});
it('skips corrupt relations', async () => {
const lines = [
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }),
JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob' }), // missing relationType
JSON.stringify({ type: 'relation', from: 'Alice', to: 'Bob', relationType: 'knows' }),
];
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(1);
expect(graph.relations).toHaveLength(1);
expect(graph.relations[0].relationType).toBe('knows');
});
it('skips malformed JSON lines', async () => {
const lines = [
JSON.stringify({ type: 'entity', name: 'Alice', entityType: 'person', observations: [] }),
'{this is not valid json',
JSON.stringify({ type: 'entity', name: 'Bob', entityType: 'person', observations: [] }),
];
await fs.writeFile(testFilePath, lines.join('\n') + '\n');
const graph = await manager.readGraph();
expect(graph.entities).toHaveLength(2);
expect(graph.entities.map(e => e.name)).toEqual(['Alice', 'Bob']);
});
});
});