Files
obelisk/tests/query.test.mjs
T

591 lines
23 KiB
JavaScript
Raw Normal View History

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createQueryApi, createAttuneApi } from '../packages/core/src/query.ts';
const require = createRequire(import.meta.url);
const { DatabaseSync } = require('node:sqlite');
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
function memoryDb({ projectPath = '/tmp/quiet-zero-test' } = {}) {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
const insertSession = db.prepare(`
INSERT INTO sessions (id, title, project, project_path, started_at, ended_at, git_branch, message_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
insertSession.run('sid-1', 'Older quiet-zero session', 'quiet-zero', projectPath, '2026-06-09T10:00:00Z', '2026-06-09T11:00:00Z', 'main', 12);
insertSession.run('sid-2', 'Memory layer session', 'quiet-zero', projectPath, '2026-06-10T10:00:00Z', '2026-06-10T11:00:00Z', 'codex/memory-layer', 23);
insertSession.run('sid-3', 'Other project session', 'other-project', '/tmp/other-project', '2026-06-11T10:00:00Z', '2026-06-11T11:00:00Z', 'main', 5);
const insert = db.prepare(`
INSERT INTO memories (id, session_id, project, path, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
insert.run('mem-1', 'sid-1', 'quiet-zero', '.obelisk/memories/parallel-agents.md', 'Decision: use parallel agents for independent review facets.', '2026-06-09T12:00:00Z');
insert.run('mem-2', 'sid-2', 'quiet-zero', '.obelisk/memories/sqlite-memory.md', 'Decision: store markdown memory records in SQLite.', '2026-06-10T12:00:00Z');
insert.run('mem-3', 'sid-3', 'other-project', '.obelisk/memories/parallel-agents.md', 'Other project note about parallel agents.', '2026-06-11T12:00:00Z');
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
return db;
}
function searchDb() {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY, title TEXT, project TEXT, started_at TEXT,
source TEXT DEFAULT 'claude'
);
CREATE TABLE messages (
uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role TEXT,
timestamp TEXT, model TEXT, cwd TEXT, content_type TEXT,
is_meta INTEGER DEFAULT 0, visibility TEXT DEFAULT 'visible',
source TEXT DEFAULT 'claude'
);
CREATE VIRTUAL TABLE messages_fts USING fts5(
uuid UNINDEXED, session_id UNINDEXED, text,
content=messages, content_rowid=rowid
);
`);
db.prepare(`
INSERT INTO sessions (id, title, project, started_at)
VALUES (?, ?, ?, ?)
`).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z');
const insert = db.prepare(`
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta, visibility)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1, 'visible');
insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'visible');
insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1, 'visible');
insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0, 'visible');
insert.run('msg-inactive', 'sid-search', 'needle superseded experiment', 'assistant', '2026-06-10T10:02:30Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'inactive');
insert.run('msg-inactive-meta', 'sid-search', 'needle superseded injected', 'user', '2026-06-10T10:02:40Z', null, '/tmp/quiet-zero', 'text', 1, 'inactive');
insert.run('msg-hidden', 'sid-search', 'needle abandoned branch', 'assistant', '2026-06-10T10:03:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0, 'hidden');
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
return db;
}
test('search falls back to safe tokenization for FTS-special input instead of throwing', () => {
const db = searchDb();
const api = createQueryApi(db);
// 'needle-reply' is FTS5 operator syntax (a hyphen). Raw MATCH would throw;
// search() must fall back to safe per-token quoting ("needle" "reply") and
// still find the message that contains both tokens.
const rows = api.search('needle-reply', { limit: 5 });
assert.deepEqual(rows.map(r => r.message.uuid), ['msg-text']);
db.close();
});
test('search exposes content_type on hits and temporal context', () => {
const db = searchDb();
const api = createQueryApi(db);
const rows = api.search('needle', { limit: 1 });
assert.equal(rows[0].message.uuid, 'msg-text');
assert.equal(rows[0].message.content_type, 'text');
assert.equal(rows[0].message.is_meta, 0);
assert.equal(rows[0].context[0].uuid, 'msg-thinking');
assert.equal(rows[0].context[0].content_type, 'thinking');
assert.equal(rows[0].context[0].is_meta, 0);
db.close();
});
test('search and thread omit meta messages by default and expose them on request', () => {
const db = searchDb();
const api = createQueryApi(db);
assert.deepEqual(api.search('injected', { limit: 5 }), []);
const withMeta = api.search('injected', { includeMeta: true, limit: 5 });
assert.equal(withMeta[0].message.uuid, 'msg-meta');
assert.equal(withMeta[0].message.is_meta, 1);
assert.deepEqual(api.search('abandoned', { includeMeta: true, limit: 5 }), []);
assert.deepEqual(api.thread('sid-search').map(m => m.uuid), ['msg-text', 'msg-thinking']);
assert.deepEqual(
api.thread('sid-search', { includeMeta: true }).map(m => m.uuid),
['msg-meta', 'msg-text', 'msg-meta-near', 'msg-thinking'],
);
assert.deepEqual(
api.thread('sid-search', { includeInactive: true }).map(m => [m.uuid, m.visibility]),
[
['msg-text', 'visible'],
['msg-thinking', 'visible'],
['msg-inactive', 'inactive'],
],
);
db.close();
});
test('inactive search is opt-in, orthogonal to meta filtering, and always labeled', () => {
const db = searchDb();
const api = createQueryApi(db);
assert.deepEqual(api.search('superseded', { limit: 5 }), []);
const inactive = api.search('superseded', { includeInactive: true, limit: 5 });
assert.deepEqual(inactive.map(row => [row.message.uuid, row.message.visibility]), [
['msg-inactive', 'inactive'],
]);
assert.equal(
inactive[0].context.every(row => row.visibility === 'visible' || row.visibility === 'inactive'),
true,
);
const withMeta = api.search('superseded', {
includeInactive: true,
includeMeta: true,
limit: 5,
});
assert.deepEqual(
withMeta.map(row => [row.message.uuid, row.message.visibility]).sort(),
[
['msg-inactive', 'inactive'],
['msg-inactive-meta', 'inactive'],
],
);
assert.deepEqual(api.search('abandoned', { includeInactive: true, includeMeta: true }), []);
db.close();
});
test('context and trace reject hidden targets and omit hidden ancestors', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-chain', 'Visibility chain', 'pi');
const insert = db.prepare(`
INSERT INTO messages (
uuid,session_id,type,parent_uuid,role,text,timestamp,visibility,source
) VALUES (?,?,?,?,?,?,?,?,?)
`);
insert.run('visible-root', 'sid-chain', 'user', null, 'user', 'root', '2026-08-02T10:00:00Z', 'visible', 'pi');
insert.run('hidden-parent', 'sid-chain', 'assistant', 'visible-root', 'assistant', 'secret', '2026-08-02T10:00:01Z', 'hidden', 'pi');
insert.run('visible-child', 'sid-chain', 'user', 'hidden-parent', 'user', 'continue', '2026-08-02T10:00:02Z', 'visible', 'pi');
insert.run('inactive-child', 'sid-chain', 'assistant', 'visible-root', 'assistant', 'superseded', '2026-08-02T10:00:03Z', 'inactive', 'pi');
const api = createQueryApi(db);
assert.equal(api.context('hidden-parent'), null);
assert.equal(api.context('hidden-parent', { includeInactive: true }), null);
assert.deepEqual(api.trace('hidden-parent'), []);
assert.deepEqual(api.trace('hidden-parent', { includeInactive: true }), []);
assert.equal(api.context('inactive-child'), null);
assert.deepEqual(api.trace('inactive-child'), []);
assert.deepEqual(
api.context('inactive-child', { includeInactive: true }).parentChain
.map(message => [message.uuid, message.visibility]),
[['visible-root', 'visible']],
);
assert.deepEqual(
api.trace('inactive-child', { includeInactive: true })
.map(message => [message.uuid, message.visibility]),
[
['visible-root', 'visible'],
['inactive-child', 'inactive'],
],
);
assert.deepEqual(
api.context('visible-child').parentChain.map(message => message.uuid),
['visible-root'],
);
assert.deepEqual(
api.trace('visible-child').map(message => message.uuid),
['visible-root', 'visible-child'],
);
db.close();
});
test('raw rejects hidden targets and labels explicitly included inactive evidence', () => {
const db = searchDb();
const providerRegistry = {
raw: ({ messageUuid }) => ({
text: `raw:${messageUuid}`,
totalLength: `raw:${messageUuid}`.length,
}),
};
const api = createQueryApi(db, { providerRegistry });
assert.equal(api.raw('msg-hidden'), null);
assert.equal(api.raw('msg-hidden', { includeInactive: true }), null);
assert.equal(api.raw('msg-inactive'), null);
assert.deepEqual(
api.raw('msg-inactive', { includeInactive: true }),
{
text: 'raw:msg-inactive',
totalLength: 16,
offset: 0,
limit: 10000,
hasMore: false,
visibility: 'inactive',
},
);
assert.equal(api.raw('msg-text').text, 'raw:msg-text');
assert.equal(api.raw('msg-text').visibility, 'visible');
db.close();
});
test('failures nextMessages does not leak hidden branch messages', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-failure', 'Failure branch', 'pi');
const insertMessage = db.prepare(`
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
VALUES (?,?,?,?,?,?,?,?)
`);
insertMessage.run('failure-result', 'sid-failure', 'user', 'toolResult', 'failed', '2026-08-02T10:00:00Z', 'visible', 'pi');
insertMessage.run('hidden-next', 'sid-failure', 'assistant', 'assistant', 'abandoned', '2026-08-02T10:00:01Z', 'hidden', 'pi');
insertMessage.run('inactive-next', 'sid-failure', 'assistant', 'assistant', 'superseded', '2026-08-02T10:00:02Z', 'inactive', 'pi');
insertMessage.run('visible-next', 'sid-failure', 'assistant', 'assistant', 'recovered', '2026-08-02T10:00:03Z', 'visible', 'pi');
db.prepare(`
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json)
VALUES (?,?,?,?,?)
`).run('call-failure', 'failure-result', 'sid-failure', 'read', '{}');
db.prepare(`
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
VALUES (?,?,?,?,?)
`).run('call-failure', 'failure-result', 'sid-failure', 'failed', 1);
const row = createQueryApi(db).failures('sid-failure')[0];
assert.deepEqual(row.nextMessages.map(message => message.uuid), ['visible-next']);
assert.equal(row.visibility, 'visible');
assert.deepEqual(
createQueryApi(db).failures({ sessionId: 'sid-failure', includeInactive: true })[0]
.nextMessages.map(message => [message.uuid, message.visibility]),
[
['inactive-next', 'inactive'],
['visible-next', 'visible'],
],
);
db.close();
});
test('failures gates both result and linked call message visibility', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-edge-visibility', 'Tool edge visibility', 'pi');
const insertMessage = db.prepare(`
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
VALUES (?,?,?,?,?,?,?,?)
`);
const insertCall = db.prepare(`
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path)
VALUES (?,?,?,?,?,?)
`);
const insertResult = db.prepare(`
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
VALUES (?,?,?,?,?)
`);
for (const [index, callVisibility] of ['visible', 'inactive', 'hidden'].entries()) {
const callId = `call-${callVisibility}`;
insertMessage.run(
`message-${callVisibility}`,
'sid-edge-visibility',
'assistant',
'assistant',
null,
`2026-08-02T10:00:0${index * 2}Z`,
callVisibility,
'pi',
);
insertMessage.run(
`result-${callVisibility}`,
'sid-edge-visibility',
'user',
'toolResult',
`failed-${callVisibility}`,
`2026-08-02T10:00:0${index * 2 + 1}Z`,
'visible',
'pi',
);
insertCall.run(
callId,
`message-${callVisibility}`,
'sid-edge-visibility',
'read',
JSON.stringify({ path: `/${callVisibility}` }),
`/${callVisibility}`,
);
insertResult.run(
callId,
`result-${callVisibility}`,
'sid-edge-visibility',
`failed-${callVisibility}`,
1,
);
}
const api = createQueryApi(db);
assert.deepEqual(
api.failures('sid-edge-visibility').map(record => record.toolCall.id),
['call-visible'],
);
assert.deepEqual(
api.failures({ sessionId: 'sid-edge-visibility', includeInactive: true })
.map(record => record.toolCall.id)
.sort(),
['call-inactive', 'call-visible'],
);
db.close();
});
test('summaries, file history, and failures expose inactive rows only on request', () => {
const db = new DatabaseSync(':memory:');
db.exec(SCHEMA);
db.prepare('INSERT INTO sessions (id,title,source) VALUES (?,?,?)')
.run('sid-structured', 'Structured visibility', 'pi');
const insertMessage = db.prepare(`
INSERT INTO messages (uuid,session_id,type,role,text,timestamp,visibility,source)
VALUES (?,?,?,?,?,?,?,?)
`);
const insertCall = db.prepare(`
INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path)
VALUES (?,?,?,?,?,?)
`);
const insertResult = db.prepare(`
INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,is_error)
VALUES (?,?,?,?,?)
`);
const insertSummary = db.prepare(`
INSERT INTO summaries (id,session_id,timestamp,source,content,visibility)
VALUES (?,?,?,?,?,?)
`);
for (const [index, visibility] of ['visible', 'inactive', 'hidden'].entries()) {
const suffix = visibility;
const uuid = `message-${suffix}`;
const callId = `call-${suffix}`;
const timestamp = `2026-08-02T10:00:0${index}Z`;
insertMessage.run(uuid, 'sid-structured', 'user', 'toolResult', suffix, timestamp, visibility, 'pi');
insertCall.run(callId, uuid, 'sid-structured', 'read', '{}', '/tmp/visibility.ts');
insertResult.run(callId, uuid, 'sid-structured', `failed-${suffix}`, 1);
insertSummary.run(`summary-${suffix}`, 'sid-structured', timestamp, 'pi:branch_summary', suffix, visibility);
}
const api = createQueryApi(db);
assert.deepEqual(api.fileHistory('/tmp/visibility.ts').map(row => row.visibility), ['visible']);
assert.deepEqual(
api.fileHistory('/tmp/visibility.ts', { includeInactive: true }).map(row => row.visibility),
['visible', 'inactive'],
);
assert.deepEqual(api.failures('sid-structured').map(row => row.visibility), ['visible']);
assert.deepEqual(
api.failures({ sessionId: 'sid-structured', includeInactive: true })
.map(row => [row.visibility, row.result.visibility]),
[
['inactive', 'inactive'],
['visible', 'visible'],
],
);
assert.deepEqual(api.summaries('sid-structured').map(row => row.visibility), ['visible']);
assert.deepEqual(
api.summaries({ sessionId: 'sid-structured', includeInactive: true })
.map(row => row.visibility),
['inactive', 'visible'],
);
db.close();
});
test('memories follows list-helper scalar opts and filters by query within scope', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.deepEqual(api.memories('sid-1').map(m => m.id), ['mem-1']);
assert.deepEqual(api.memories(1).map(m => m.id), ['mem-3']);
assert.deepEqual(
api.memories({ project: '%quiet-zero%', query: 'parallel agents', limit: 5 }).map(m => m.id),
['mem-1'],
);
db.close();
});
test('memories requires English query terms', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.throws(
() => api.memories({ query: '记忆层', limit: 5 }),
/memories\(\) query must use English terms/,
);
db.close();
});
test('memories uses FTS recall with safe English tokenization and rank', () => {
const db = memoryDb();
const api = createQueryApi(db);
const rows = api.memories({ project: '%quiet-zero%', query: 'sqlite-memory', limit: 5 });
assert.deepEqual(rows.map(m => m.id), ['mem-2']);
assert.equal(typeof rows[0].rank, 'number');
db.close();
});
test('memories does not broaden punctuation-only FTS queries into full recall', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.deepEqual(api.memories({ project: '%quiet-zero%', query: '---', limit: 5 }), []);
db.close();
});
test('overview returns a compact current-project map with bounded sessions', () => {
const db = memoryDb({ projectPath: process.cwd() });
const api = createQueryApi(db);
const view = api.overview({ limit: 1, projectLimit: 5 });
assert.equal(view.current.cwd, process.cwd());
assert.equal(view.current.project.project, 'quiet-zero');
assert.equal(view.current.project.source, 'cwd_project_path');
assert.equal(view.current.project.confidence, 'exact');
assert.equal('session' in view.current, false);
assert.equal(view.current_project.session_total, 2);
assert.deepEqual(view.current_project.sessions.map(s => s.id), ['sid-2']);
assert.equal(view.current_project.memory_total, 2);
assert.deepEqual(view.current_project.memories.map(m => m.id), ['mem-2', 'mem-1']);
assert.equal(view.totals.projects, 2);
assert.equal(view.totals.sessions, 3);
assert.equal(view.totals.memories, 3);
assert.ok(view.projects.some(p => p.project === 'quiet-zero' && p.session_count === 2 && p.memory_count === 2));
db.close();
});
test('query api is read-only and does not expose attune helpers', () => {
const db = memoryDb();
const api = createQueryApi(db);
assert.equal(api.remember, undefined);
assert.equal(api.forget, undefined);
assert.equal(typeof api.overview, 'function');
assert.deepEqual(api.sql('SELECT id FROM memories ORDER BY id').map(r => r.id), ['mem-1', 'mem-2', 'mem-3']);
assert.throws(
() => api.sql("INSERT INTO memories (id, path, summary) VALUES ('mem-x', '/tmp/x.md', 'x')"),
/sql\(\) only supports read-only SELECT\/WITH queries/,
);
db.close();
});
test('attune api exposes only memory mutation helpers', () => {
const db = memoryDb();
const api = createAttuneApi(db);
assert.deepEqual(Object.keys(api).sort(), ['forget', 'remember']);
assert.equal(api.search, undefined);
assert.equal(api.sql, undefined);
assert.equal(typeof api.remember, 'function');
assert.equal(typeof api.forget, 'function');
db.close();
});
test('remember stores absolute project-relative memory path', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const memoryDir = join(projectDir, '.obelisk', 'memories');
mkdirSync(memoryDir, { recursive: true });
const memoryPath = join(memoryDir, 'decision.md');
writeFileSync(memoryPath, '# Decision\n');
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
const result = api.remember({
path: '.obelisk/memories/decision.md',
session_id: 'sid-1',
summary: 'Decision: store normalized memory paths.',
});
assert.equal(result.path, memoryPath);
assert.equal(db.prepare('SELECT path FROM memories WHERE id=?').get(result.id).path, memoryPath);
db.close();
});
test('remember updates FTS recall for the registered memory immediately', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const memoryDir = join(projectDir, '.obelisk', 'memories');
mkdirSync(memoryDir, { recursive: true });
const memoryPath = join(memoryDir, 'query-plan.md');
writeFileSync(memoryPath, '# Query Plan\n');
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
const registered = api.remember({
path: '.obelisk/memories/query-plan.md',
session_id: 'sid-2',
summary: 'Decision: use faceted query plans for synthesis recall.',
});
const rows = createQueryApi(db).memories({
project: '%quiet-zero%',
query: 'faceted query plans',
limit: 5,
});
assert.deepEqual(rows.map(m => m.id), [registered.id]);
assert.equal(typeof rows[0].rank, 'number');
db.close();
});
test('forget soft-deletes memory records from active recall', () => {
const db = memoryDb();
const api = createAttuneApi(db);
const result = api.forget({ id: 'mem-1', reason: 'Outdated project guidance.' });
assert.equal(result.id, 'mem-1');
assert.equal(result.deleted_reason, 'Outdated project guidance.');
assert.match(result.deleted_at, /^\d{4}-\d{2}-\d{2}T/);
const row = db.prepare('SELECT deleted_at, deleted_reason FROM memories WHERE id=?').get('mem-1');
assert.equal(row.deleted_at, result.deleted_at);
assert.equal(row.deleted_reason, 'Outdated project guidance.');
assert.deepEqual(createQueryApi(db).memories({ project: '%quiet-zero%', limit: 10 }).map(m => m.id), ['mem-2']);
db.close();
});
test('remember requires English summaries', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const memoryDir = join(projectDir, '.obelisk', 'memories');
mkdirSync(memoryDir, { recursive: true });
const memoryPath = join(memoryDir, 'decision.md');
writeFileSync(memoryPath, '# Decision\n');
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
assert.throws(
() => api.remember({
path: '.obelisk/memories/decision.md',
session_id: 'sid-1',
summary: '决策:记忆摘要必须使用英文。',
}),
/remember\(\) summary must be written in English/,
);
db.close();
});
test('remember rejects missing memory files', () => {
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
const db = memoryDb({ projectPath: projectDir });
const api = createAttuneApi(db);
assert.throws(
() => api.remember({
path: '.obelisk/memories/missing.md',
session_id: 'sid-1',
summary: 'Decision: this should not be registered.',
}),
/remember\(\) memory file does not exist/,
);
db.close();
});