Files
gbrain/test/turn-context.test.ts
Garry TanandClaude Fable 5 6411150071 v0.45.11.0 feat(bootstrap): OOBE hand-off (own the brain + cold-start) + DX polish waves (#4047)
* feat(bootstrap): TTY DX exploration harness + Krug onboarding fix wave

Add a real-PTY exploration harness and land 16 verified "Don't Make Me
Think" fixes on the paste-in install experience for Claude Code and Codex.

Harness:
- test/helpers/tty-harness.ts — spawns any CLI (gbrain/claude/codex) under a
  real pseudo-terminal (Bun terminal: spawn), timestamps every output burst,
  and turns silence windows into a measurable stall report. Hermetic; pure
  helpers unit-tested in test/tty-harness.test.ts.
- scripts/dx-explore.ts — drives the fresh-user funnel (help / init / real
  claude-install / real codex-install / manual drive mode), writing
  transcripts to .context/dx-runs/ (gitignored).

Fixes (all adversarially verified against the code first):
- Keyless bare `gbrain init` completes in keyless mode instead of exit 1;
  multi-key non-TTY auto-picks the canonical default; typo stays fail-loud.
- Provider picker probe-gates ollama (daemon-up != model-pulled) and offers
  an explicit "continue keyless" option that is the bare-Enter default.
- Fresh-brain init prints one schema-setup line instead of ~240 migration
  names (GBRAIN_MIGRATE_VERBOSE=1 restores detail).
- Init epilogue: memory-verbs funnel is last-on-screen; skills advisory
  compacted for init; Mod Status trimmed.
- PGLite live-serve lock error names the fix (close the agent session).
- Mode-picker banner interpolates the applied mode; expansion-key gate is
  Anthropic/OpenAI/Google, not OpenAI-only.
- Missing `claude` binary skips MCP but still installs hooks; honest copy.
- Foreign MCP-registration removal targets the conflicting scope and fails
  loud if it does not land.
- Upgrade marker compares the running binary to latest and self-spawns via
  execPath, so a current/newer binary no longer nags from a stale cache.
- interview --set/--skip after --confirm warns it voided the confirmation.
- init --help matches behavior; init --supabase fails loud on non-TTY.
- Provider capabilities attributed per provider across README / runbook /
  questions bank / bootstrap.md.
- First-run tour: restart-first, prompt 3 true on day one, withheld on FAIL;
  README gives Codex the same scripted magic moment.
- Empty-brain "0 takes" onboard nudge suppressed.
- Broken settings.local.json aborts the hooks write fail-closed instead of
  silently dropping the user's permissions.

Regenerated cli-flag-registry.generated.ts and llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): second DX polish wave — clean the success screen + honest copy (F17-F21)

Follow-up to the DX fix wave, closing the top-5 remaining gaps the scorecard
flagged (all human-facing polish, not survival):

F17 — machine markers no longer leak to humans:
- verify report drops the `[D3.6]` plan-tag from the first_run_tour detail.
- the raw `UPGRADE_AVAILABLE <cur> <latest>` marker line prints ONLY on a
  non-TTY stderr (parsers still get it); an interactive human sees just the
  "gbrain X -> Y available" sentence.
- per-migration "what changed" notices (v123/v124, incl. the #2704 ref) are
  suppressed on a FRESH-install replay via a module quiet flag; upgrades still
  narrate. (GBRAIN_MIGRATE_VERBOSE=1 restores them.)

F18 — one obvious next action on the init success screen: the memory-verbs
demo is the single "→ Do this next" hero, last on screen; import/migrate/doctor
collapse into one terse "More:" footer; the graph block only shows for a
non-empty brain.

F19 — README "moment it clicks" is now the genuine cross-session brain
round-trip (remember → restart → recall), explicitly distinguished from the
identity-file recall, on both the Codex and Claude Code paths.

F20 — the compact init skills advisory is human-voiced (no `[AGENT]`
stage-direction on the human-facing success screen; the mode-picker's
agent-directed block stays gated to the non-TTY channel).

F21 — time promise reconciled: headline is ~15 min (personal-agent path) /
~30 min (always-on OpenClaw/Hermes); the runbook's search-mode line no longer
claims "balanced" when keyless applies "conservative". README hooks copy says
"on by default, with an opt-out" to match the runbook.

Regenerated llms-full.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): address two-model adversarial review of the DX wave

Fixes the regressions the 5-specialist + red-team + Claude/Codex adversarial
pass found in the F1–F21 changes, each with a test:

- Keyless upgrade hint pointed at `config set embedding_model`, which config.ts
  hard-refuses as a schema-sizing no-op — now names the working re-init recipe
  (`gbrain init --force --pglite --embedding-model <id>`), zero-key AND multi-key
  paths.
- Multi-key TTY picker offered "continue keyless" but the caller aborted on it —
  now honors keyless like the zero-key path.
- Detached update-refresh spawn used a `/gbrain$/` basename check that misfires
  for a renamed/official-named compiled binary (`gbrain-darwin-arm64`) and
  prepends the /$bunfs entrypoint — now detects dev-vs-compiled by the runtime
  basename (bun|node) so the refresh always runs.
- `bootstrap status` reported the wire phase "done" on a hooks-only receipt
  (host CLI missing at wire time) — now "partial" with a re-run hint, so a
  resuming agent doesn't trust a false complete.
- Post-repair MCP mismatch re-verifies and aborts instead of blessing a
  registration a racing writer may have re-claimed.
- probeOpenAICompat's abort timer now spans the body read (was cleared before
  it), so a stalled `/v1/models` body can't hang init past the 1s cap.
- Centralized the 4-copy stale-cache upgrade predicate into
  `pendingUpgradeVersion`; UPGRADE_AVAILABLE gains a GBRAIN_FORCE_UPGRADE_MARKER
  override for PTY-based agent harnesses.
- Mode picker's expansion-key gate adds GEMINI_API_KEY; picker prompt is
  article-aware ("an embedding" / "a chat"); dead `!brainEmpty` clause removed;
  migrate.ts try/finally widened + stamp failures named in quiet mode.
- DX harness: credential copies scrubbed even on SIGINT/interrupt (+chmod 600),
  child process TREE reaped on teardown, advisory made fail-open, KEY_MAP typed
  as a literal union.

New tests: migrate quiet-replay, self-upgrade pending predicate + negative
cache cases, bootstrap 127/scoped-remove/broken-settings dispatch, interview
invalidation flag, verify tour-withheld-on-FAIL, init keyless/supabase/multi-key,
init-nudge branches, ai-probes model parsing. Regenerated flag registry +
template-repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.8.0 fix(bootstrap): onboarding DX polish wave (F17-F21) + review fixes

DX fix wave on the paste-in install/first-run experience for Claude Code and
Codex, driven by a new real-PTY exploration harness. Keyless init completes
instead of erroring, the migration wall collapses to one line, the success
screen leads with one action, and the "magic moment" copy points at the genuine
cross-session round-trip. Full detail in CHANGELOG.

Version trio + openclaw manifest + runbook stamp bumped to 0.45.8.0; CHANGELOG
release entry; TODOS onboarding-DX follow-ups filed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): OOBE hand-off — you own the brain, cold-start is skill #1

A working install now ends by making the two facts that matter actually land:

- `gbrain bootstrap verify` prints (and returns as `handoff` in --json) an
  ownership block — the actual private-repo URL with what owning it means
  (read it, `gbrain bootstrap attach` on machine two, delete it and the brain
  is gone), or the local-only variant pointing at `gbrain bootstrap repo` —
  followed by the ONE next action: run the cold-start skill (Gmail/calendar/
  contacts via ClawVisor, an OAuth vault so the agent never holds raw tokens;
  or offline archives), one consented phase at a time. Withheld on FAIL like
  the tour; shape stays unconditional for machine consumers.
- cold-start ships in the downstream bundle (61 skills): its plugin exclusion
  ("host onboarding flow") predated the v0.45 personal-agent bootstrap and is
  deliberately reversed — the paste-in audience is exactly who day-one
  onboarding is for. It now LEADS the recommended set (ahead of book-mirror:
  every flagship skill only becomes magical once the brain holds the user's
  real life).
- New drift guard: every recommended slug must be scaffoldable from the
  plugin bundle — recommended-but-unscaffoldable is a dead-end CTA and now
  fails the suite.
- Runbook Hand off rewritten around the two must-land facts + the on-the-spot
  cold-start offer; README's Codex and Claude Code paths carry the same two
  follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.10.0 feat(bootstrap): the OOBE hand-off release

Version trio + runbook stamp + template tree to 0.45.10.0; CHANGELOG entry;
llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ci): stop memory-verbs-conformance leaking a fake-keyed gateway into shard-mates

The deterministic-embedder helper configures the MODULE-GLOBAL gateway with a
fake OpenAI key; the file's afterAll never reset it. The bunfig preload's
per-test restore only fires when the gateway is UNCONFIGURED, so the fake-keyed
config persisted for every later file in the shard process — turn-context's
corpus writes then embedded against real OpenAI and 401'd (CI shard-8 failure;
shard re-binning from this branch's new test files exposed it).

Fix both sides: conformance's afterAll now resetGateway()s back to the preload
baseline and nulls both test transports; turn-context's beforeAll does the same
defensively so it stays hermetic regardless of shard composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:35:05 -07:00

408 lines
19 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 {
resetGateway,
__setChatTransportForTests,
__setEmbedTransportForTests,
} from '../src/core/ai/gateway.ts';
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 () => {
// This file's corpus writes fire embed attempts iff the module-global
// gateway looks configured AND keyed. A shard-mate can leave it configured
// with a fake test key — the preload's beforeEach only restores when the
// gateway is UNCONFIGURED, so that state persists and put_page 401s against
// real OpenAI (the shard-8 flake). Reset back to the preload baseline
// (real process.env → keyless degrade on CI) regardless of shard-mates.
resetGateway();
__setChatTransportForTests(null);
__setEmbedTransportForTests(null);
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);
});