mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* refactor(context): extract pure gateVolunteeredPointers (no behavior change) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): volunteer-shaped resolve IPC — channel/cwd/volunteer fields, handler-result widening, server-side gate + validated source + delivery-point logging; listener on both engines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cli): gbrain volunteer-hook — one-shot harness hook entry (IPC-first; Postgres-only fallback; startup-hook-exempt) T0 evidence: a UserPromptSubmit hook's additionalContext is recorded in the session transcript as a structured hook_additional_context attachment and transcript_path is stable across turns — cross-turn dedupe reads ONLY those attachments (never raw tail substrings). Live smoke: seeded brain + running serve → pointer injected via IPC at 0.43s wall, turn 2 suppressed, one event banked channel=claude-code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(recipes): claude-code-reflex + codex-reflex (experimental) hook adapters Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(doctor): volunteer_channels per-adapter visibility Engine-aware sibling of retrieval_reflex_health: groups context_volunteer_events by channel (7d) so operators can see which push adapters fire; message separates installed-but-unregistered from registered-but-quiet; pre-v117 tolerant; info-only status. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): hook-lane feedback loop + cross-turn dedupe — converge cathedral-3 onto the #3975 harness hooks The #3975 hook lane volunteered pages invisibly (nothing logged to context_volunteer_events) and re-volunteered the same page every turn an entity was mentioned (no priorContextText). This convergence ports the cathedral-3 branch's two gap-fills into the blessed lane and retires its parallel surface: - transcripts: parseTranscript extracts injectedContextBlocks (structured hook_additional_context attachments — the T0-verified recorded shape of our own injections; never raw-turn substring matching) - hook user-prompt: feeds those blocks as priorContextText (volunteer once per session, not once per mention) + sends a feedback-loop channel (--harness <claude-code|codex>, default claude-code); hook joins STARTUP_HOOK_SKIP_COMMANDS (per-prompt check-update spawn class, grep-pinned) - turn-context: result exposes post-trim volunteered survivors - resolve-ipc: additive TurnContextRequest.channel + onTurnContextDelivered seam (fires post-write only — abandoned blocks are never counted) - serve: delivery callback logs volunteered pages + pointers per channel - doctor: volunteer_channels check (per-channel 7d visibility; separates installed-but-unregistered from registered-but-quiet; pre-v117 tolerant) Retired (never released): gbrain volunteer-hook, hook-adapters.ts, claude-code-reflex + codex-reflex recipes, the volunteer-shaped resolve wire extension. Kept: gateVolunteeredPointers extraction, VolunteerChannel claude-code/codex values, the real hook-transcript fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: regenerate llms bundles for the hook-lane doc updates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: coverage for the hook-lane delivery wiring Extract logTurnContextDeliveryFireAndForget into volunteer-events.ts so the shipped serve callback is unit-testable (channel fallback, sessionId clamp, pointer+page rows in ONE synchronous registration — the double dynamic import could register the pending write too late to survive process exit). Direct units for isVolunteerChannel, explicit-channel logDeliveredReflexPointers, and the pure gate (idempotency pin; stale parity claim in its doc corrected). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: pre-landing review fixes — 7-reviewer batch Multi-confirmed: volunteer_channels now runs on the LOCAL doctor path too (buildChecks; docs pointed there); transient DB errors no longer misreported as pre-v117; priorContextText deduped + 32KB-capped hook-side. Red team: delivery counts reconciled against the hook heartbeat (CAUTION when mostly degraded — write-accept isn't injection); IPC clamp drops advisory priorContextText BEFORE any window turn; foreign hook_additional_context blocks (no gbrain marker) excluded from dedupe input; engine-aware quiet guidance (Postgres brains aren't told to chase hook registration); 2MB dedupe horizon documented. Security: wire channel claims restricted to harness channels. DRY: VOLUNTEER_CHANNELS single source, shared reflexPointerRationale, SESSION_ID_MAX_LEN shared with the op clamp; dead channel param dropped from logDeliveredReflexPointers. Tests: budget-trim volunteered invariant under real trim pressure, poll/ordering-based delivery-seam tests (no fixed sleeps), doctor branch + engine-parity-row tests, wiring source pins, cap pins; fixture pruned to load-bearing lines (environment inventory removed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial-review fixes — cross-model P1 + Claude adversarial batch P1 (codex structured review + Claude adversarial, cross-model): the remote run_doctor path now threads the caller's source scope into volunteer_channels — a source-bound token can no longer see other sources' activity counts/timestamps (must-never-violate source-isolation rule); local trusted doctor stays brain-wide. Claude adversarial: IPC connection handler gains a one-request re-entrancy guard (trailing bytes mid-await double-processed the same line → duplicate handler work + duplicate delivery logging); partial client-side trims now heartbeat as reason 'trimmed' (delivery-count drift visibility); heartbeat reconciliation uses the same 7-day window as the counts + a 5-sample floor; one oversized dedupe block no longer evicts all smaller ones (continue, not break); quiet-channel guidance no longer claims old serves attribute hook traffic to reflex (they log nothing); 'transient' claim softened to evidence; marker-filter + unknown-channel-default comments state their honest limits. Pins: scoped-query SQL/params, re-entrancy single-fire, updated wiring grep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v0.45.1.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: restamp bootstrap runbook + regenerate vendored template repo for v0.45.1.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v0.45.1.0 Post-ship /document-release pass: fold the post-doc review fixes into the reference docs (KEY_FILES: source-scoped remote volunteer_channels + heartbeat reconciliation, 32KB-capped dedupe input, marker filter, IPC one-request guard + clamp order), fix BRAINBENCH's now-stale memoryless-hook rationale, and mark the doctor-check half of the push-context TODO shipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: cross-model doc-review fixes for v0.45.1.0 Codex doc review against the shipped diff: push-context channel table gains the harness-hook row (three-channels claim was stale), the guide states the PGLite-socket topology limit and the bounded dedupe horizon, delivery-point claims are hedged to match the code (write-accept isn't proof of injection; the doctor reconciles against the hook heartbeat, which can't inspect registration), BRAINBENCH's future-tense hooks-PR sentence updated now the hook has landed, and the adapter-flip + resolver-channel TODOS reflect the current lane count. llms-full.txt regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(hook): document --harness in the user-prompt usage text Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
394 lines
18 KiB
TypeScript
394 lines
18 KiB
TypeScript
/**
|
|
* Turn-context assembly tests (agent-bootstrap plan: S3#1 visibility fence,
|
|
* ENG-1 8KB budget, ENG-11 hot-memory cache reuse, CX-P1.2 subordinate
|
|
* envelope, CX2-11 typed session identity + two-session cache isolation).
|
|
*
|
|
* Hermetic in-memory PGLite; engine-agnostic assembly (no engine-specific SQL
|
|
* in the module under test).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import {
|
|
assembleTurnContext,
|
|
TURN_CONTEXT_ENVELOPE,
|
|
TURN_CONTEXT_DEFAULT_MAX_BYTES,
|
|
} from '../src/core/context/turn-context.ts';
|
|
import {
|
|
getBrainHotMemoryMeta,
|
|
bumpHotMemoryCache,
|
|
__resetHotMemoryCacheForTests,
|
|
} from '../src/core/facts/meta-hook.ts';
|
|
import { buildOperationContext } from '../src/mcp/dispatch.ts';
|
|
import type { OperationContext } from '../src/core/operations.ts';
|
|
import type { GBrainConfig } from '../src/core/config.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
import type { WindowTurn } from '../src/core/context/entity-salience.ts';
|
|
import {
|
|
loadCorpusPages,
|
|
loadCorpusBeliefs,
|
|
loadCorpusBeliefData,
|
|
loadCorpusQueries,
|
|
} from './helpers/bootstrap-corpus.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
__resetHotMemoryCacheForTests();
|
|
await engine.executeRaw('DELETE FROM page_aliases').catch(() => {});
|
|
await engine.executeRaw('DELETE FROM facts').catch(() => {});
|
|
await engine.executeRaw('DELETE FROM pages').catch(() => {});
|
|
});
|
|
|
|
async function seedPage(slug: string, title: string, body: string) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
|
|
VALUES ($1, 'default', 'person', $2, $3, '')`,
|
|
[slug, title, body],
|
|
);
|
|
}
|
|
|
|
async function seedFact(fact: string, visibility: 'private' | 'world', opts: { session?: string; entity?: string } = {}) {
|
|
await engine.insertFact(
|
|
{
|
|
fact,
|
|
kind: 'fact',
|
|
entity_slug: opts.entity ?? 'people/alice-example',
|
|
source: 'test',
|
|
visibility,
|
|
source_session: opts.session ?? null,
|
|
},
|
|
{ source_id: 'default' },
|
|
);
|
|
}
|
|
|
|
describe('assembleTurnContext', () => {
|
|
test('envelope first, pointers + world facts in; private facts NEVER [CX-P1.2, S3#1]', async () => {
|
|
await seedPage('people/alice-example', 'Alice Example', 'Alice Example is a founder at acme-example.');
|
|
await seedFact('WORLD-FACT alice prefers async updates', 'world');
|
|
await seedFact('PRIVATE-FACT sensitive valuation note', 'private');
|
|
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [{ role: 'user', text: 'catching up with Alice Example tomorrow' }],
|
|
});
|
|
|
|
expect(r.text.startsWith(TURN_CONTEXT_ENVELOPE)).toBe(true);
|
|
expect(r.pointers.length).toBe(1);
|
|
expect(r.pointers[0].slug).toBe('people/alice-example');
|
|
expect(r.text).toContain('people/alice-example');
|
|
expect(r.text).toContain('WORLD-FACT');
|
|
expect(r.text).not.toContain('PRIVATE-FACT');
|
|
expect(r.text).not.toContain('sensitive valuation note');
|
|
expect(r.factsCount).toBe(1);
|
|
expect(r.degradedReason).toBeUndefined();
|
|
expect(Buffer.byteLength(r.text, 'utf8')).toBeLessThanOrEqual(TURN_CONTEXT_DEFAULT_MAX_BYTES);
|
|
});
|
|
|
|
test('volunteer section dedupes against reflex pointers (slug appears once)', async () => {
|
|
await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.');
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [
|
|
{ role: 'user', text: 'Alice Example pinged me' },
|
|
{ role: 'assistant', text: 'noted — Alice Example wants a follow-up' },
|
|
],
|
|
});
|
|
const hits = r.text.split('`people/alice-example`').length - 1;
|
|
expect(hits).toBe(1);
|
|
});
|
|
|
|
test('result.volunteered carries the POST-trim survivors (feedback-loop input, never the pre-budget pool)', async () => {
|
|
// Two distinct entities: one resolves as a reflex pointer (subject of the
|
|
// newest turn), the other only via the volunteer arm.
|
|
await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.');
|
|
await seedPage('companies/widget-co', 'Widget Co', 'Widget Co company page.');
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [
|
|
{ role: 'user', text: 'Widget Co update?' },
|
|
{ role: 'user', text: 'and what about Widget Co and Alice Example today?' },
|
|
],
|
|
});
|
|
// Whatever the split between arms, the union of pointers + volunteered is
|
|
// exactly what the rendered text carries — the delivery-point logger's
|
|
// contract (logging a trimmed-out page would corrupt precision stats).
|
|
const surfaced = [...r.pointers.map((p) => p.slug), ...(r.volunteered ?? []).map((v) => v.slug)];
|
|
for (const slug of surfaced) {
|
|
expect(r.text).toContain(slug);
|
|
}
|
|
expect(surfaced.length).toBeGreaterThan(0);
|
|
// No overlap between the arms (volunteer dedupes against pointers).
|
|
expect(new Set(surfaced).size).toBe(surfaced.length);
|
|
// volunteered is always present on a fresh assembly (empty when none).
|
|
expect(Array.isArray(r.volunteered)).toBe(true);
|
|
});
|
|
|
|
test('budget-trimmed volunteered pages are excluded from result.volunteered (never the pre-budget pool)', async () => {
|
|
// Five distinct entities: pointers arm takes its cap, the volunteer arm
|
|
// takes the rest; a tight maxBytes then forces the volunteered while-loop
|
|
// to drop at least one. The invariant under test: result.volunteered is
|
|
// EXACTLY the set present in the rendered text — a copy-before-trim
|
|
// refactor would silently log trimmed-out pages to the precision stats.
|
|
// Lowercase lead-in: a capitalized verb would merge into the first
|
|
// candidate's capitalized run and drop it from extraction.
|
|
const names = ['Aaa Corp', 'Bbb Corp', 'Ccc Corp', 'Ddd Corp', 'Eee Corp', 'Fff Corp', 'Ggg Corp'];
|
|
for (const n of names) {
|
|
const slug = `companies/${n.split(' ')[0].toLowerCase()}`;
|
|
await seedPage(slug, n, `${n} — ${'synopsis filler '.repeat(20)}.`);
|
|
}
|
|
const window = [{ role: 'user' as const, text: `we saw ${names.join(', ')} today` }];
|
|
const full = await assembleTurnContext(engine, { sourceId: 'default', window });
|
|
const fullVolunteered = full.volunteered ?? [];
|
|
expect(fullVolunteered.length).toBeGreaterThan(1); // needs ≥2 to trim meaningfully
|
|
|
|
// Budget sized to keep the block but force dropping ≥1 volunteered page.
|
|
const tight = Math.max(600, Buffer.byteLength(full.text, 'utf8') - 150);
|
|
const r = await assembleTurnContext(engine, { sourceId: 'default', window, maxBytes: tight });
|
|
expect(r.degradedReason).toBe('budget_trimmed');
|
|
const trimmed = r.volunteered ?? [];
|
|
expect(trimmed.length).toBeLessThan(fullVolunteered.length); // ≥1 dropped
|
|
for (const v of trimmed) expect(r.text).toContain(v.slug); // survivors ARE in the text
|
|
const droppedSlugs = fullVolunteered.map((v) => v.slug).filter((s) => !trimmed.some((v) => v.slug === s));
|
|
expect(droppedSlugs.length).toBeGreaterThan(0);
|
|
for (const s of droppedSlugs) expect(r.text).not.toContain(s); // dropped are NOT in the text
|
|
});
|
|
|
|
test('budget trims facts BEFORE pointers, lowest confidence first [ENG-1]', async () => {
|
|
await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.');
|
|
for (let i = 0; i < 8; i++) {
|
|
await seedFact(`WORLD-FACT-${i} ${'detail '.repeat(20)}`, 'world');
|
|
}
|
|
const maxBytes = 500;
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [{ role: 'user', text: 'sync with Alice Example' }],
|
|
maxBytes,
|
|
});
|
|
expect(Buffer.byteLength(r.text, 'utf8')).toBeLessThanOrEqual(maxBytes);
|
|
expect(r.degradedReason).toBe('budget_trimmed');
|
|
// The pointer survives — facts absorb the trim first.
|
|
expect(r.pointers.length).toBe(1);
|
|
expect(r.text).toContain('people/alice-example');
|
|
expect(r.factsCount).toBeLessThan(8);
|
|
});
|
|
|
|
test('absurdly small budget → empty text rather than a broken envelope', async () => {
|
|
await seedPage('people/alice-example', 'Alice Example', 'Alice Example founder profile.');
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [{ role: 'user', text: 'ping Alice Example' }],
|
|
maxBytes: 10,
|
|
});
|
|
expect(r.text).toBe('');
|
|
expect(r.degradedReason).toBe('budget_trimmed');
|
|
});
|
|
|
|
test('empty window still serves the hot-memory digest (session-start case)', async () => {
|
|
await seedFact('WORLD-FACT standup moved to 9am', 'world');
|
|
const r = await assembleTurnContext(engine, { sourceId: 'default', window: [] });
|
|
expect(r.pointers.length).toBe(0);
|
|
expect(r.factsCount).toBe(1);
|
|
expect(r.text).toContain('WORLD-FACT standup moved to 9am');
|
|
expect(r.text.startsWith(TURN_CONTEXT_ENVELOPE)).toBe(true);
|
|
});
|
|
|
|
test('nothing to inject → empty text, zero counts, no degradation', async () => {
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [{ role: 'user', text: 'no entities here, just vibes' }],
|
|
});
|
|
expect(r.text).toBe('');
|
|
expect(r.pointers.length).toBe(0);
|
|
expect(r.factsCount).toBe(0);
|
|
expect(r.degradedReason).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('session cache isolation [CX2-11]', () => {
|
|
function metaCtx(sessionId?: string): OperationContext {
|
|
return {
|
|
engine,
|
|
config: {} as GBrainConfig,
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: true,
|
|
sourceId: 'default',
|
|
sessionId,
|
|
takesHoldersAllowList: ['world'],
|
|
};
|
|
}
|
|
|
|
test('two sessions on one source cache DIFFERENT fact sets per session key', async () => {
|
|
await seedFact('SESSION-A-FACT prefers morning meetings', 'world', { session: 'sess-a' });
|
|
await seedFact('SESSION-B-FACT prefers evening meetings', 'world', { session: 'sess-b' });
|
|
|
|
const a = await getBrainHotMemoryMeta('get_stats', metaCtx('sess-a'));
|
|
const b = await getBrainHotMemoryMeta('get_stats', metaCtx('sess-b'));
|
|
const aFacts = (a!.brain_hot_memory as { facts: Array<{ fact: string }> }).facts.map((f) => f.fact);
|
|
const bFacts = (b!.brain_hot_memory as { facts: Array<{ fact: string }> }).facts.map((f) => f.fact);
|
|
expect(aFacts).toContain('SESSION-A-FACT prefers morning meetings');
|
|
expect(aFacts).not.toContain('SESSION-B-FACT prefers evening meetings');
|
|
expect(bFacts).toContain('SESSION-B-FACT prefers evening meetings');
|
|
expect(bFacts).not.toContain('SESSION-A-FACT prefers morning meetings');
|
|
expect((a!.brain_hot_memory as { session_id: string }).session_id).toBe('sess-a');
|
|
expect((b!.brain_hot_memory as { session_id: string }).session_id).toBe('sess-b');
|
|
});
|
|
|
|
test('cache entries are per-session: bumping one session leaves the other cached', async () => {
|
|
await seedFact('SESSION-A-FACT original', 'world', { session: 'sess-a' });
|
|
await seedFact('SESSION-B-FACT original', 'world', { session: 'sess-b' });
|
|
|
|
// Prime both cache entries.
|
|
await getBrainHotMemoryMeta('get_stats', metaCtx('sess-a'));
|
|
await getBrainHotMemoryMeta('get_stats', metaCtx('sess-b'));
|
|
|
|
// New session-A fact is invisible until session A's entry is bumped.
|
|
await seedFact('SESSION-A-FACT fresher', 'world', { session: 'sess-a' });
|
|
const cachedA = await getBrainHotMemoryMeta('get_stats', metaCtx('sess-a'));
|
|
const cachedAFacts = (cachedA!.brain_hot_memory as { facts: Array<{ fact: string }> }).facts.map((f) => f.fact);
|
|
expect(cachedAFacts).not.toContain('SESSION-A-FACT fresher'); // still cached
|
|
|
|
bumpHotMemoryCache('default', 'sess-a');
|
|
const freshA = await getBrainHotMemoryMeta('get_stats', metaCtx('sess-a'));
|
|
const freshAFacts = (freshA!.brain_hot_memory as { facts: Array<{ fact: string }> }).facts.map((f) => f.fact);
|
|
expect(freshAFacts).toContain('SESSION-A-FACT fresher');
|
|
|
|
// Session B's cached entry was untouched by the session-A bump.
|
|
const stillB = await getBrainHotMemoryMeta('get_stats', metaCtx('sess-b'));
|
|
const stillBFacts = (stillB!.brain_hot_memory as { facts: Array<{ fact: string }> }).facts.map((f) => f.fact);
|
|
expect(stillBFacts).toEqual(['SESSION-B-FACT original']);
|
|
});
|
|
});
|
|
|
|
describe('dispatch typed session identity [CX2-11]', () => {
|
|
const fakeEngine = {} as unknown as BrainEngine;
|
|
|
|
test('_meta.session_id inside tool arguments lands on ctx.sessionId', () => {
|
|
const ctx = buildOperationContext(fakeEngine, { _meta: { session_id: 'topic-42' } }, { remote: true, sourceId: 'default' });
|
|
expect(ctx.sessionId).toBe('topic-42');
|
|
});
|
|
|
|
test('session id is clamped to 256 chars', () => {
|
|
const long = 'x'.repeat(300);
|
|
const ctx = buildOperationContext(fakeEngine, { _meta: { session_id: long } }, { remote: true, sourceId: 'default' });
|
|
expect(ctx.sessionId!.length).toBe(256);
|
|
const viaOpts = buildOperationContext(fakeEngine, {}, { remote: true, sourceId: 'default', sessionId: long });
|
|
expect(viaOpts.sessionId!.length).toBe(256);
|
|
});
|
|
|
|
test('transport-resolved opts.sessionId wins over arguments-level _meta', () => {
|
|
const ctx = buildOperationContext(
|
|
fakeEngine,
|
|
{ _meta: { session_id: 'from-args' } },
|
|
{ remote: true, sourceId: 'default', sessionId: 'from-transport' },
|
|
);
|
|
expect(ctx.sessionId).toBe('from-transport');
|
|
});
|
|
|
|
test('non-string / absent _meta.session_id → undefined (never a crash)', () => {
|
|
expect(buildOperationContext(fakeEngine, {}, { sourceId: 'default' }).sessionId).toBeUndefined();
|
|
expect(buildOperationContext(fakeEngine, { _meta: { session_id: 42 } }, { sourceId: 'default' }).sessionId).toBeUndefined();
|
|
expect(buildOperationContext(fakeEngine, { _meta: 'nope' }, { sourceId: 'default' }).sessionId).toBeUndefined();
|
|
expect(buildOperationContext(fakeEngine, { _meta: { session_id: '' } }, { sourceId: 'default' }).sessionId).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('corpus recall on a multi-entity brain [S3#1 fence, real recall]', () => {
|
|
// Deterministic proper-case surface for a slug tail so the zero-LLM,
|
|
// proper-case-biased entity resolver fires on a page-kind query.
|
|
// 'concepts/graph-traversal' → 'Graph Traversal'
|
|
const nameFromSlug = (slug: string): string =>
|
|
slug
|
|
.split('/')
|
|
.pop()!
|
|
.split('-')
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(' ');
|
|
|
|
test('every gold query: world answer surfaces, private beliefs are fenced, named pages resolve to pointers', async () => {
|
|
// beforeEach cleared pages/facts/aliases. Seed the WHOLE synthetic world
|
|
// through the real handlers: put_page fires auto-link + search vector,
|
|
// insertFact honors each belief's declared visibility.
|
|
const slugs = await loadCorpusPages(engine, { sourceId: 'default' });
|
|
expect(slugs.length).toBeGreaterThanOrEqual(12);
|
|
const inserted = await loadCorpusBeliefs(engine, { sourceId: 'default' });
|
|
expect(inserted).toBe(loadCorpusBeliefData().length);
|
|
// Beliefs were inserted after any prior meta read this test — start the
|
|
// hot-memory cache clean so the world/private tiers are (re)computed.
|
|
__resetHotMemoryCacheForTests();
|
|
|
|
const privateTexts = loadCorpusBeliefData()
|
|
.filter((b) => b.visibility === 'private')
|
|
.map((b) => b.text);
|
|
expect(privateTexts.length).toBeGreaterThanOrEqual(3);
|
|
|
|
const queries = loadCorpusQueries();
|
|
expect(queries.length).toBeGreaterThan(0);
|
|
|
|
// Collect every violation with its gold-case id so a regression names the
|
|
// exact query + assertion that broke, instead of a bare boolean.
|
|
const violations: string[] = [];
|
|
for (const q of queries) {
|
|
// Page-kind: name the entity so the reflex resolver runs against the real
|
|
// 12-page graph. belief / fence: the raw query — only the world/private
|
|
// hot-memory fence decides what surfaces.
|
|
const window: WindowTurn[] =
|
|
q.kind === 'page' && q.expect_slug
|
|
? [{ role: 'user', text: `the latest on ${nameFromSlug(q.expect_slug)}` }]
|
|
: [{ role: 'user', text: q.query }];
|
|
const r = await assembleTurnContext(engine, { sourceId: 'default', window });
|
|
|
|
// Fence (S3#1): NO private belief text ever crosses into the block,
|
|
// regardless of what the user asked.
|
|
for (const pt of privateTexts) {
|
|
if (r.text.includes(pt)) violations.push(`${q.id}: leaked private belief "${pt.slice(0, 40)}…"`);
|
|
}
|
|
if (q.must_not_substring && r.text.includes(q.must_not_substring)) {
|
|
violations.push(`${q.id}: fenced substring surfaced "${q.must_not_substring}"`);
|
|
}
|
|
if (q.kind === 'belief' && q.expect_substring && !r.text.includes(q.expect_substring)) {
|
|
violations.push(`${q.id}: world belief not recalled "${q.expect_substring}"`);
|
|
}
|
|
if (q.kind === 'page' && q.expect_slug) {
|
|
if (!r.pointers.some((p) => p.slug === q.expect_slug)) {
|
|
violations.push(`${q.id}: entity did not resolve to a pointer (${q.expect_slug})`);
|
|
}
|
|
if (!r.text.includes(q.expect_slug)) {
|
|
violations.push(`${q.id}: expected slug missing from the block (${q.expect_slug})`);
|
|
}
|
|
}
|
|
}
|
|
expect(violations).toEqual([]);
|
|
}, 120_000);
|
|
|
|
test('reflex pointer synopsis carries real page-body content, not a generic stub', async () => {
|
|
await loadCorpusPages(engine, { sourceId: 'default' });
|
|
// Named entity → pointer, and the pointer's synopsis is drawn from THAT
|
|
// page's body (the world-fenced first prose line), so distinctive body
|
|
// content reaches the assembled block.
|
|
const cases: Array<[name: string, slug: string, needle: string]> = [
|
|
['Summit Robotics', 'companies/summit-robotics', 'warehouse'],
|
|
['Graph Traversal', 'concepts/graph-traversal', 'path-planning'],
|
|
['Hybrid Retrieval', 'concepts/hybrid-retrieval', 'dense embeddings'],
|
|
];
|
|
for (const [name, slug, needle] of cases) {
|
|
const r = await assembleTurnContext(engine, {
|
|
sourceId: 'default',
|
|
window: [{ role: 'user', text: `context on ${name}` }],
|
|
});
|
|
expect(r.pointers.map((p) => p.slug)).toContain(slug);
|
|
expect(r.text).toContain(needle);
|
|
}
|
|
}, 120_000);
|
|
});
|