Files
gbrain/test/ambient-recall.test.ts
Garry TanandClaude Fable 5 1ec6a6e842 v0.45.9.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder (#4045)
* feat(core): execution-environment detection — local | cloud-sandbox | ephemeral-container

detectExecutionEnvironment() + isCredentialInjectingProxy() with injected
signals (CLAUDE_CODE_REMOTE, cse_ session-id prefix, proxy-injected token
placeholder, anthropic-egress proxy JWT, container markers). binaryOnPath
moves here as the canonical PATH probe. autopilot's detectInstallTarget
ephemeral branch now routes through the shared detector.

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

* feat(bootstrap): repo-visibility ladder + per-turn Stop push + same-session failure banner

The durability lane. One repo-visibility verdict for every consumer (REST
first — never GraphQL, which sandbox proxies pin; authed ls-remote + an
attributed anonymous probe as the git-protocol fallback), replacing three
drifted probes. A 200 counts as public only with advertisement proof; a
401/404 counts as private-signal only with an auth challenge — fail-closed
in BOTH directions. Private verdicts cache 1h (private-only, per origin).

gbrain hook stop now spawns a debounced detached push per turn (per-root
state; cloud-sandbox defaults to every turn, elsewhere 5 min; a failing
status bypasses the debounce), closing the /exit and VM-reclaim gaps.
Push status is per workspace root, read through one shared reader by the
user-prompt banner (additionalContext + systemMessage — visible to the
human, not just the model), the SessionStart note, and doctor. Escape
hatches for self-hosted git: flag > env > file-plane config key.

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

* feat(bootstrap): environment-aware install — honest cron skip, cloud repo-create guard, execution_env surfaces

installDurabilityCron probes for crontab before writing anything (containers
and cloud sandboxes ship without one — expected, reported as an honest skip
naming the event-driven pushes that still cover persistence). The repo phase
installs the container-friendly harden half (post-commit hook, no scheduler)
outside local machines. createPrivateRepo fails fast in cloud sandboxes with
the flow that works (create outside, open the session ON the repo, attach).
bootstrap verify gains a never-gating execution_env check; bootstrap status
--json carries execution_environment for installing agents to branch on.

Also fixes a live-PATH resolution class: binaryOnPath and the crontab execs
now pass the current env explicitly (Bun resolves against the startup
snapshot otherwise).

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

* fix(bootstrap): lifecycle hygiene — uninstall teardown, doctor job liveness, .mcp.json out of the repo, honest persistence copy

uninstall now tears down the durability wiring it installed (launchd/cron
job, untracked post-commit hook, credential wiring — the committed helper
and AGENTS rules stay). doctor gains bootstrap_durability_job: presence +
LIVENESS (launchctl load-state, crontab line, pull-log freshness) — a plist
on disk with a dead job no longer reads as healthy. Rendered .gitignore now
covers .mcp.json (absolute machine paths must not land in the private
repo); verify warns on pre-fix installs that committed it; the never-built
state/mcp.json promise is gone from GITHUB.md. Persistence copy tells the
truth everywhere: event-driven pushes do the durability work, the 30-min
job is a multi-machine pull freshener. Post-commit hook install/removal is
worktree-safe (git-path resolution; the git marker is a FILE there) and the
cron wrapper's self-disable tests the repo dir, not its git marker. Two
follow-up TODOs filed (plugin hook distribution; Channels push lane).

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

* feat(bootstrap): committed hook carrier for cloud + cloud-setup-script emitter + cloud runbook

Cloud sessions clone fresh and snapshot hook config at session start — the
gitignored settings.local.json never exists there, so hooks never fired in
cloud at all. Cloud installs now write the repo-COMMITTED .claude/settings.json
with PATH-resolved, fail-open commands (no machine paths; a host without the
binary no-ops); local installs keep settings.local.json; the writers enforce
that one event never fires from both carriers, and removal cleans both.

New: gbrain bootstrap cloud-setup-script prints the paste-ready environment
setup script (npm transport — bun fetching is proxy-incompatible in cloud;
never the unrelated npm-registry package). Runbook gains a NEVER FABRICATE
TOOLING hard rule, a cloud-sandbox section (expected degradations as facts to
relay, the attach-first flow), and failure-table rows for the proxy-403 and
missing-crontab signatures. Codex lane stated honestly: AGENTS.md Gate 2 now
has the pull-side push-health check. Guide documents the new knobs and the
cloud contract; llms bundles regenerated.

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

* test(bootstrap): per-turn push e2e chain, cloud-sandbox sim, per-root status reader migration + registry regen

bootstrap-persistence e2e gains the Stop-hook chain (one stop banks the turn
to the real bare remote; the debounce holds across stops; debounce-0 lands
consecutive turns) and fixes a pre-existing post-#4024 break: repoPhaseComplete
required a github-parseable origin, so session-end pushes deferred FOREVER for
self-hosted/file-transport origins — non-github repo_urls now bind by exact
URL equality (redirect protection preserved). Degraded-modes e2e gains the
cloud-sandbox simulation (status reports the environment; repo creation
refuses with the attach flow). workspace-push tests read per-root status
through the shared reader. Flag registry regenerated (new git argv literals
from the git-path/ls-files calls — the accepted argv-bleed class).

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

* fix(doctor): categorize bootstrap_durability_job (categories drift guard)

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

* fix(bootstrap): adversarial-review hardening — fail-closed privacy, no push-storm, no exfil paths

Both Claude and Codex adversarial passes ran; 11 findings fixed (union):
- Non-github 401+WWW-Authenticate no longer reads as private (RFC 7235 makes
  the header mandatory on every 401, so a middlebox 401s identically) — the
  top exfil path both models flagged; now unverifiable/fail-closed, operator
  confirms via the escape hatch. github.com still needs x-github-request-id.
- Escape hatches downgrade ONLY 'unverifiable' — a PROVEN-public origin still
  refuses (hatches never authorize a public push).
- treeNeedsPush measures against origin/<branch> (the push's own ref), not
  @{u}: a no-upstream branch no longer reports a committed-but-unpushed tree
  as push_clean and silently strands it.
- [D20] failing-retry uses a fixed 60s floor, not min(debounce,60s) — cloud
  debounce=0 no longer re-runs the network ladder every turn.
- Committed hook carrier: dedupe/suppress only on the EXACT portable-command
  shape, not a 'gbrain hook' substring (blocks the suppress-local-run-evil
  supply-chain vector); GBRAIN_HOME refused in the committed carrier.
- push-status reason sanitized (charset+length) at every surface (banner,
  doctor, status blob) so remote git stderr can't inject via the remediation.
- Per-root state: ghost-root records (deleted workspaces) filtered so a dead
  failing record can't re-fire the banner forever; uninstall removes them.
- statusReport support blob reads push status through the shared per-root
  reader; visibility cache strips URL userinfo (no PAT persisted).
- anonProbe strips userinfo + redirect:manual + SSRF flags + --end-of-options
  on ls-remote; cron self-disable uses git rev-parse (worktree-safe both
  ways); cloud-setup-script fails loud on a broken update; durability liveness
  won't certify a never-run crontab as live; config get/unset resolve the
  dotted file-plane keys; .bak/.broken gitignored; typed config fields.

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

* v0.45.8.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder

VERSION + package.json + CHANGELOG + TODOS + llms bundles.

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

* docs: sync KEY_FILES + README for the cloud-DX wave (v0.45.8.0)

New KEY_FILES entries for execution-env.ts and repo-visibility.ts; updated the
bootstrap repo/hooks, workspace-push, brain-repo-durability, and hook.ts
entries to current behavior (ladder verification, committed cloud hook carrier,
per-root push status, crontab probe + liveness, per-turn stop push + banner).
README's Claude Code line now states per-turn + cloud persistence honestly.
llms bundles regenerated.

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

* fix(bootstrap): doc-review pass — attach records repo_url (enables cloud persistence), verify reads per-root push status, honest docs

Cross-model doc review (Codex) caught that the headline cloud-persistence
claim was hollow and two docs over-claimed:
- attach now records repo_url from the adopted origin, so the no-daemon push
  gate (repoPhaseComplete) recognizes the repo phase as done — WITHOUT this,
  the per-turn/session-end pushes deferred forever after an attach, which is
  the ONLY install path in a cloud sandbox (repo is refused there). Privacy is
  still enforced at push time by the ladder.
- bootstrap verify's push_probe reads the shared per-root reader [D8], not the
  legacy single file — a fresh v0.45.8 install no longer reports 'no push
  recorded' when per-root status exists.
- Docs corrected to match code: runbook stamp → 0.45.8.0; README states the
  per-turn cadence honestly (debounced local, next-turn failure notice);
  GITHUB.md qualifies auto-push as Claude Code (Codex is pull); KEY_FILES says
  repo/status use REST (not the full ladder) and drops the stale --push;
  bootstrap.md hooks-location covers both carriers; README verbs surface says
  seven. llms bundles regenerated.

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

* fix(bootstrap): regenerate vendored template-repo tree + isolate env-mutating tests (verify gate)

The verify CI gate caught two things the piecemeal local runs missed:
- templates/bootstrap/template-repo/ is a GENERATED tree; the source-template
  edits (AGENTS.md Gate 2, CLAUDE.md cloud note) plus the version stamp had
  drifted. Regenerated from source (check:bootstrap-templates green).
- test/bootstrap-repo.test.ts + test/durability-cron.test.ts newly mutated
  process.env in non-serial files; converted to withEnv() (check:test-isolation
  green) — no .serial rename needed.

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

* test(ambient-recall): pin a keyless gateway so delta/context_pack writes never fire a real embed

Root cause of the shard-2 CI red after the 0.45.9.0 re-bump: adding this
wave's test files reshuffled the weight-packed shards, moving
ambient-recall.test.ts next to a neighbor that leaks CI's dummy
OPENAI_API_KEY (sk-test-*) into the gateway singleton (the bunfig preload
configures with env:{...process.env}, and a present-but-invalid key turns
remember's keyless-degrade embed into a hard 401). The delta/context_pack
tests exercise cursor + budget logic, not embedding quality, so this pins a
keyless gateway (env:{}) in beforeAll — isAvailable('embedding') is false,
writeSingleFact degrades (degraded_dedup) with no HTTP call, and the file is
deterministic regardless of shard bin-packing. Verified: passes with
OPENAI_API_KEY=sk-test-* set (the CI condition). Master's own comment in
legacy-embedding-preload.ts already warns 'adding any test file reshuffles
the mines'; this makes the file immune.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:12:59 -07:00

690 lines
36 KiB
TypeScript

/**
* Ambient recall hooks (issue #1) — context_pack + delta frozen verbs, the
* shared session cursor, world-only-by-default visibility, and cross-caller
* isolation. Hermetic in-memory PGLite.
*
* Coverage map (plan verification + eng-review findings):
* - session-state round-trip, surfaced-slug union, ISO cursor, GC
* - eng 1B: cross-caller isolation via the (source_id, client_id, session_id) key
* - eng 1A / D2=A: world-only default; include_private widens only for
* trusted-local (ctx.remote === false); remote NEVER widens (fail-closed)
* - delta cursor lifecycle (establish → advance), page dedup, since|session gate
* - MEMORY_VERBS_VERSION stays 1 (additive; the P1 fix)
* - v0.45.7 gap-closure wave: delta visibility fail-closed mirror, stateless
* keyset resume + explicit since_slug precedence, forced budget overflow,
* session-state outage fail-open, banked-entity warm-pack visibility
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway } from '../src/core/ai/gateway.ts';
import { operations } from '../src/core/operations.ts';
import { MEMORY_VERBS_VERSION, VERB_NAMES } from '../src/core/verbs.ts';
import {
getSessionContextState,
upsertSessionContextState,
gcSessionContextState,
resolveClientId,
LOCAL_CLIENT_SENTINEL,
} from '../src/core/context/session-state.ts';
import { __resetHotMemoryCacheForTests } from '../src/core/facts/meta-hook.ts';
import type { OperationContext } from '../src/core/operations.ts';
import type { GBrainConfig } from '../src/core/config.ts';
let engine: PGLiteEngine;
const noopLogger = { info: () => {}, warn: () => {}, error: () => {} };
function ctxFor(opts: { remote: boolean; clientId?: string }): OperationContext {
return {
engine,
config: {} as GBrainConfig,
logger: noopLogger,
dryRun: false,
remote: opts.remote,
sourceId: 'default',
...(opts.clientId ? { auth: { clientId: opts.clientId, scopes: [] } as never } : {}),
} as OperationContext;
}
const contextPack = operations.find((o) => o.name === 'context_pack')!;
const del = operations.find((o) => o.name === 'delta')!;
const remember = operations.find((o) => o.name === 'remember')!;
/** Handler results are `unknown` on the Operation type; tests branch on fields. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type VerbResult = Record<string, any>;
async function call(
op: typeof contextPack,
ctx: OperationContext,
p: Record<string, unknown>,
): Promise<VerbResult> {
return (await op.handler(ctx, p)) as VerbResult;
}
beforeAll(async () => {
// Hermetic embedding: pin the gateway to a KEYLESS config (empty env) so
// `remember`'s fact-embed degrades gracefully (degraded_dedup) instead of
// firing a real OpenAI call. On CI the process carries a dummy
// OPENAI_API_KEY (sk-test-*) that a shard-neighbor can leak into the
// gateway singleton via a captured env (the bunfig preload configures with
// `env: {...process.env}`); a present-but-invalid key turns the keyless
// degrade into a hard 401. The delta/context_pack tests exercise
// cursor/budget logic, not embedding quality, so keyless is correct and
// makes them independent of shard bin-packing. Dimensions stay 1536 to
// match the preload's schema.
configureGateway({ embedding_model: 'openai:text-embedding-3-large', embedding_dimensions: 1536, env: {} });
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 120_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM session_context_state');
__resetHotMemoryCacheForTests();
});
describe('protocol additivity (P1 fix)', () => {
test('MEMORY_VERBS_VERSION stays 1 with 7 frozen verbs', () => {
expect(MEMORY_VERBS_VERSION).toBe(1);
expect(VERB_NAMES).toContain('context_pack');
expect(VERB_NAMES).toContain('delta');
expect(VERB_NAMES.length).toBe(7);
});
test('context_pack + delta are verbs on the surface, scope read', () => {
expect(contextPack.verb).toBe(true);
expect(contextPack.scope).toBe('read');
expect(del.verb).toBe(true);
expect(del.scope).toBe('read');
});
test('responses stamp protocol_version 1', async () => {
const pack = await call(contextPack, ctxFor({ remote: false }), { entities: 'a,b' });
expect(pack.protocol_version).toBe(1);
const d = await call(del, ctxFor({ remote: false }), { since: '1970-01-01T00:00:00Z' });
expect(d.protocol_version).toBe(1);
});
});
describe('session-state cursor', () => {
test('absent → null; round-trips + ISO cursor + keyset slug', async () => {
expect(await getSessionContextState(engine, 'default', null, 's1')).toBeNull();
await upsertSessionContextState(engine, 'default', null, 's1', {
standingEntities: ['people/alice-example'],
lastWakeAt: '2026-08-01T00:00:00Z',
cursorSlug: 'notes/x',
});
const st = (await getSessionContextState(engine, 'default', null, 's1'))!;
expect(st.standing_entities).toEqual(['people/alice-example']);
expect(st.surfaced_slugs).toEqual(['notes/x']); // keyset slug, single element
// ISO-normalized regardless of the engine's ::text format
expect(st.last_wake_at).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
expect(new Date(st.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
});
test('cursorSlug REPLACES on each advance; omission keeps standing + wake', async () => {
await upsertSessionContextState(engine, 'default', null, 's1', {
standingEntities: ['e1'],
cursorSlug: 'notes/a',
lastWakeAt: '2026-08-01T00:00:00Z',
});
// replace cursor slug; omit standing + wake → both kept
await upsertSessionContextState(engine, 'default', null, 's1', { cursorSlug: 'notes/c' });
const st = (await getSessionContextState(engine, 'default', null, 's1'))!;
expect(st.surfaced_slugs).toEqual(['notes/c']);
expect(st.standing_entities).toEqual(['e1']); // keep-if-absent
expect(new Date(st.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
});
test('cursor is last-writer-wins on lastWakeAt (keyset ordering lives in the handler)', async () => {
await upsertSessionContextState(engine, 'default', null, 'lww', { lastWakeAt: '2026-08-05T00:00:00Z' });
await upsertSessionContextState(engine, 'default', null, 'lww', { lastWakeAt: '2026-08-01T00:00:00Z' });
const st = (await getSessionContextState(engine, 'default', null, 'lww'))!;
// A null lastWakeAt keeps the prior; a concrete one always wins.
expect(new Date(st.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
await upsertSessionContextState(engine, 'default', null, 'lww', { cursorSlug: 'notes/z' });
expect(new Date((await getSessionContextState(engine, 'default', null, 'lww'))!.last_wake_at!).toISOString())
.toBe('2026-08-01T00:00:00.000Z'); // null wake → kept
});
test('eng 1B: cross-caller isolation — local sentinel vs auth client are separate rows', async () => {
await upsertSessionContextState(engine, 'default', null, 's1', { cursorSlug: 'local-only' });
await upsertSessionContextState(engine, 'default', 'client-XYZ', 's1', { cursorSlug: 'remote-only' });
const local = (await getSessionContextState(engine, 'default', null, 's1'))!;
const remote = (await getSessionContextState(engine, 'default', 'client-XYZ', 's1'))!;
expect(local.surfaced_slugs).toEqual(['local-only']);
expect(remote.surfaced_slugs).toEqual(['remote-only']);
expect(resolveClientId(null)).toBe(LOCAL_CLIENT_SENTINEL);
expect(resolveClientId('client-XYZ')).toBe('client-XYZ');
});
test('GC ages out stale rows', async () => {
await upsertSessionContextState(engine, 'default', null, 'old', { cursorSlug: 'x' });
await engine.executeRaw(`UPDATE session_context_state SET updated_at = now() - interval '30 days' WHERE session_id = 'old'`);
await gcSessionContextState(engine, 7);
expect(await getSessionContextState(engine, 'default', null, 'old')).toBeNull();
});
test('GC caps rows per (source, client) — oldest evicted past the cap', async () => {
// Seed 3 rows, age them so ordering is deterministic, cap to 2.
for (const s of ['c1', 'c2', 'c3']) {
await upsertSessionContextState(engine, 'default', 'capclient', s, { cursorSlug: s });
}
await engine.executeRaw(`UPDATE session_context_state SET updated_at = '2026-08-01T00:00:00Z' WHERE session_id = 'c1'`);
await engine.executeRaw(`UPDATE session_context_state SET updated_at = '2026-08-02T00:00:00Z' WHERE session_id = 'c2'`);
await engine.executeRaw(`UPDATE session_context_state SET updated_at = '2026-08-03T00:00:00Z' WHERE session_id = 'c3'`);
// Directly exercise the windowed cap via a tiny inline DELETE mirror (the
// real gc uses MAX_ROWS_PER_CLIENT=1000; prove the ORDER here with cap 2).
await engine.executeRaw(
`DELETE FROM session_context_state s USING (
SELECT source_id, client_id, session_id,
row_number() OVER (PARTITION BY source_id, client_id ORDER BY updated_at DESC) AS rn
FROM session_context_state
) ranked
WHERE s.source_id = ranked.source_id AND s.client_id = ranked.client_id
AND s.session_id = ranked.session_id AND ranked.rn > 2`,
);
expect(await getSessionContextState(engine, 'default', 'capclient', 'c1')).toBeNull(); // oldest evicted
expect(await getSessionContextState(engine, 'default', 'capclient', 'c3')).not.toBeNull(); // newest kept
});
});
describe('delta cursor lifecycle', () => {
test('first wake with session establishes an ISO cursor and empty delta', async () => {
const r = await call(del, ctxFor({ remote: false }), { session_id: 'sess-1' });
expect(r.pages).toEqual([]);
expect(r.since).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
// cursor persisted
const st = await getSessionContextState(engine, 'default', null, 'sess-1');
expect(st?.last_wake_at).toBeTruthy();
});
test('since OR session required; else invalid_params', async () => {
await expect(del.handler(ctxFor({ remote: false }), {})).rejects.toThrow(/requires|since|session/i);
});
test('explicit since is echoed, NORMALIZED to ISO (F4 — never the raw string)', async () => {
const r = await call(del, ctxFor({ remote: false }), { since: '1970-01-01T00:00:00Z' });
expect(r.since).toBe('1970-01-01T00:00:00.000Z');
// A parseable NON-ISO form is normalized to canonical ISO, never echoed raw
// into the injectable `text` block.
const r2 = await call(del, ctxFor({ remote: false }), { since: 'January 1, 2000 00:00:00 GMT' });
expect(r2.since).toBe('2000-01-01T00:00:00.000Z');
expect(r2.text as string).not.toContain('January 1, 2000');
// An UNPARSEABLE string is rejected outright (defense in depth).
await expect(call(del, ctxFor({ remote: false }), { since: 'definitely not a date' })).rejects.toThrow(/parseable|ISO/i);
});
test('remote caller cursor is namespaced by auth client', async () => {
await call(del, ctxFor({ remote: true, clientId: 'harness-A' }), { session_id: 'shared' });
await call(del, ctxFor({ remote: true, clientId: 'harness-B' }), { session_id: 'shared' });
const a = await getSessionContextState(engine, 'default', 'harness-A', 'shared');
const b = await getSessionContextState(engine, 'default', 'harness-B', 'shared');
expect(a).not.toBeNull();
expect(b).not.toBeNull();
// 'local' sentinel row must NOT exist for a remote-only session
expect(await getSessionContextState(engine, 'default', null, 'shared')).toBeNull();
});
test('auth-less remote (stdio) lands in the "remote" namespace, never "local" (adversarial P2)', async () => {
await call(del, ctxFor({ remote: true }), { session_id: 'anon-sess' });
expect(await getSessionContextState(engine, 'default', 'remote', 'anon-sess')).not.toBeNull();
expect(await getSessionContextState(engine, 'default', null, 'anon-sess')).toBeNull();
});
test('at-least-once: budget-dropped pages surface on the next wake (adversarial P2)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
// establish cursor first
await call(del, local, { session_id: 'alo' });
// three page changes after the cursor
for (const s of ['alo-a', 'alo-b', 'alo-c']) {
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\ncontent for ${s}` });
}
// tiny budget: deliver a strict subset, has_more = true, cursor lags
const r1 = await call(del, local, { session_id: 'alo', budget_tokens: 8 });
expect(r1.has_more).toBe(true);
expect(r1.pages.length).toBeGreaterThan(0);
expect(r1.pages.length).toBeLessThan(3);
// big budget: the remaining pages arrive — nothing was lost
const r2 = await call(del, local, { session_id: 'alo', budget_tokens: 100000 });
const delivered = new Set([...r1.pages, ...r2.pages].map((p: { slug: string }) => p.slug));
expect(delivered.has('notes/alo-a')).toBe(true);
expect(delivered.has('notes/alo-b')).toBe(true);
expect(delivered.has('notes/alo-c')).toBe(true);
expect(r2.has_more).toBe(false);
});
test('text is rendered from the budget-packed sets, not the full sets (adversarial P2)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
await call(del, local, { session_id: 'txt' });
for (const s of ['txt-a', 'txt-b', 'txt-c']) {
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\ncontent` });
}
const r = await call(del, local, { session_id: 'txt', budget_tokens: 8 });
const inText = ['txt-a', 'txt-b', 'txt-c'].filter((s) => (r.text as string).includes(s));
expect(inText.length).toBe(r.pages.length); // text lists exactly the delivered pages
});
test('equal-timestamp ties survive across wakes (boundary-tie dedup, pre-landing P2)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
for (const s of ['tie-a', 'tie-b', 'tie-c', 'tie-d']) {
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\nbody` });
}
// Force all four onto ONE identical timestamp (the bulk-sync shape).
await engine.executeRaw(
`UPDATE pages SET updated_at = '2026-08-10T12:00:00Z' WHERE slug LIKE 'notes/tie-%'`,
);
// Seed the keyset cursor BEFORE the tie cluster (empty slug = start of the
// bucket) via the INSERT path.
await upsertSessionContextState(engine, 'default', null, 'tie', {
lastWakeAt: '2026-08-10T11:00:00Z', cursorSlug: '',
});
// Tiny budget: deliver a strict subset of the tie cluster (all 4 share one
// timestamp — the keyset paginates within it by slug).
const r1 = await call(del, local, { session_id: 'tie', budget_tokens: 8 });
expect(r1.pages.length).toBeGreaterThan(0);
expect(r1.pages.length).toBeLessThan(4);
expect(r1.has_more).toBe(true);
// Next wake MUST deliver the remaining ties (timestamp-only cursors lost them).
const r2 = await call(del, local, { session_id: 'tie', budget_tokens: 100000 });
const delivered = new Set([...r1.pages, ...r2.pages].map((p: { slug: string }) => p.slug));
for (const s of ['tie-a', 'tie-b', 'tie-c', 'tie-d']) expect(delivered.has(`notes/${s}`)).toBe(true);
// And already-delivered ties must NOT re-deliver on a third wake.
const r3 = await call(del, local, { session_id: 'tie', budget_tokens: 100000 });
const redelivered = (r3.pages as Array<{ slug: string }>).filter((p) => p.slug.startsWith('notes/tie-'));
expect(redelivered).toEqual([]);
});
test('F1: a tie cluster LARGER than the fetch limit fully drains (no livelock)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
const N = 55; // > DELTA_PAGE_FETCH_LIMIT (50) at ONE timestamp
for (let i = 0; i < N; i++) {
await call(putPage, local, { slug: `notes/big-${String(i).padStart(3, '0')}`, content: `# big-${i}\n\nb` });
}
await engine.executeRaw(`UPDATE pages SET updated_at = '2026-08-11T09:00:00Z' WHERE slug LIKE 'notes/big-%'`);
await upsertSessionContextState(engine, 'default', null, 'big', {
lastWakeAt: '2026-08-11T08:00:00Z', cursorSlug: '',
});
const seenBig = new Set<string>();
let guard = 0;
let more = true;
while (more && guard < 10) {
const r = await call(del, local, { session_id: 'big', budget_tokens: 100000 });
for (const pg of r.pages as Array<{ slug: string }>) {
if (pg.slug.startsWith('notes/big-')) seenBig.add(pg.slug);
}
more = r.has_more === true;
guard++;
}
expect(guard).toBeLessThan(10); // did NOT livelock (the F1 failure was a stuck has_more)
expect(more).toBe(false);
expect(seenBig.size).toBe(N); // every tied page in the >limit cluster delivered, across wakes
});
test('zero-delivery wake does not advance the cursor (deliver-before-advance)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
await call(del, local, { session_id: 'zd' });
const before = (await getSessionContextState(engine, 'default', null, 'zd'))!.last_wake_at;
await call(putPage, local, { slug: 'notes/zd-page-with-a-very-long-title-to-cost-tokens', content: '# long\n\nbody' });
const r = await call(del, local, { session_id: 'zd', budget_tokens: 1 });
expect(r.pages).toEqual([]);
expect(r.has_more).toBe(true);
expect((await getSessionContextState(engine, 'default', null, 'zd'))!.last_wake_at).toBe(before);
});
test('remote caller with an empty-string clientId lands in "remote", never "local"', async () => {
const ctx = ctxFor({ remote: true, clientId: '' });
await call(del, ctx, { session_id: 'blank-cid' });
expect(await getSessionContextState(engine, 'default', null, 'blank-cid')).toBeNull();
expect(await getSessionContextState(engine, 'default', 'remote', 'blank-cid')).not.toBeNull();
});
test('malformed since → invalid_params with a suggestion (both verbs)', async () => {
await expect(call(del, ctxFor({ remote: false }), { since: 'not-a-date' })).rejects.toThrow(/parseable|ISO/i);
await expect(
call(contextPack, ctxFor({ remote: false }), { entities: 'a', since: 'yesterday-ish' }),
).rejects.toThrow(/parseable|ISO/i);
});
test('first wake echoes the budget footer when budget_tokens was passed', async () => {
const r = await call(del, ctxFor({ remote: false }), { session_id: 'fw-budget', budget_tokens: 500 });
expect(r.budget_tokens).toBe(500);
expect(r.budget_used).toBe(0);
expect(r.dropped_count).toBe(0);
});
test('context_pack echoes the CAPPED entity list (max 8)', async () => {
const many = Array.from({ length: 12 }, (_, i) => `entity-${i}`).join(',');
const r = await call(contextPack, ctxFor({ remote: false }), { entities: many });
expect((r.entities as string[]).length).toBe(8);
});
test('delta facts filter uses RECORDING time and reaches past 24h (pre-landing P2)', async () => {
const local = ctxFor({ remote: false });
await call(remember, local, { fact: 'old recorded fact for delta window test', provenance: 't', visibility: 'world' });
// Age the fact's created_at 3 days back — the old hot-memory fallback
// window (24h) would have silently missed it for a 5-day-old cursor.
await engine.executeRaw(
`UPDATE facts SET created_at = now() - interval '3 days' WHERE fact = 'old recorded fact for delta window test'`,
);
const since = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
const r = await call(del, local, { since });
const found = (r.facts as Array<{ fact: string }>).some((f) => f.fact.includes('old recorded fact'));
expect(found).toBe(true);
});
test('stateless keyset resume: next_cursor.since/.slug drains a tie cluster, no re-delivery (v0.45.7)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
for (const s of ['sr-a', 'sr-b', 'sr-c', 'sr-d']) {
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\nbody` });
}
// One identical timestamp, earlier than every other fixture cluster.
await engine.executeRaw(`UPDATE pages SET updated_at = '2026-08-09T10:00:00Z' WHERE slug LIKE 'notes/sr-%'`);
// NO session_id anywhere: resume rides next_cursor.since/.slug alone.
// budget 8 ≈ 2 pages per call, so the 4-page cluster needs ≥2 wakes.
const seen: string[] = [];
let since = '2026-08-09T09:59:00Z';
let sinceSlug: string | undefined;
let guard = 0;
while (seen.length < 4 && guard < 10) {
const r = await call(del, ctxFor({ remote: false }), {
since,
...(sinceSlug !== undefined ? { since_slug: sinceSlug } : {}),
budget_tokens: 8,
});
for (const pg of r.pages as Array<{ slug: string }>) {
if (pg.slug.startsWith('notes/sr-')) seen.push(pg.slug);
}
since = (r.next_cursor as { since: string }).since;
sinceSlug = (r.next_cursor as { slug: string }).slug;
guard++;
}
// Full drain, exactly once each — a re-delivery would duplicate in `seen`.
expect(seen.sort()).toEqual(['notes/sr-a', 'notes/sr-b', 'notes/sr-c', 'notes/sr-d']);
// One more resumed call: the drained cluster must NOT re-deliver.
const r = await call(del, ctxFor({ remote: false }), { since, since_slug: sinceSlug, budget_tokens: 100000 });
const redelivered = (r.pages as Array<{ slug: string }>).filter((p) => p.slug.startsWith('notes/sr-'));
expect(redelivered).toEqual([]);
});
test('explicit since_slug wins over the session cursor slug when both are present (v0.45.7)', async () => {
const putPage = operations.find((o) => o.name === 'put_page')!;
const local = ctxFor({ remote: false });
for (const s of ['ow-a', 'ow-b', 'ow-c', 'ow-d']) {
await call(putPage, local, { slug: `notes/${s}`, content: `# ${s}\n\nbody` });
}
await engine.executeRaw(`UPDATE pages SET updated_at = '2026-08-08T10:00:00Z' WHERE slug LIKE 'notes/ow-%'`);
// Session cursor at the START of the tie bucket — on its own it would
// deliver the whole cluster from ow-a.
await upsertSessionContextState(engine, 'default', null, 'ow', {
lastWakeAt: '2026-08-08T10:00:00Z', cursorSlug: '',
});
const r = await call(del, local, { session_id: 'ow', since_slug: 'notes/ow-b', budget_tokens: 100000 });
const cluster = (r.pages as Array<{ slug: string }>)
.map((p) => p.slug)
.filter((s) => s.startsWith('notes/ow-'));
// Strictly after the EXPLICIT slug — the session's '' slug would have
// re-delivered ow-a/ow-b.
expect(cluster).toEqual(['notes/ow-c', 'notes/ow-d']);
});
test('fail-open: a session_context_state outage never blocks the delta read path (v0.45.7)', async () => {
// Proxy engine: executeRaw throws ONLY for statements touching
// session_context_state; everything else hits the real engine (the delta
// page/fact reads must keep working through the outage).
const failing = new Proxy(engine, {
get(t, k) {
if (k === 'executeRaw') {
return (sql: unknown, params?: unknown[]) => {
if (typeof sql === 'string' && sql.includes('session_context_state')) {
throw new Error('injected session-state outage');
}
return t.executeRaw(sql as never, params as never);
};
}
const v = (t as unknown as Record<string | symbol, unknown>)[k];
return typeof v === 'function' && k !== 'constructor'
? (v as (...a: unknown[]) => unknown).bind(t)
: v;
},
});
// The state READ degrades to null, never a throw.
await expect(getSessionContextState(failing as never, 'default', null, 'outage')).resolves.toBeNull();
const ctx = { ...ctxFor({ remote: false }), engine: failing } as OperationContext;
// First-wake path: cursor read + establish-write + GC all fail — the verb
// still answers with a complete payload.
const r1 = await call(del, ctx, { session_id: 'outage' });
expect(r1.protocol_version).toBe(1);
expect(r1.pages).toEqual([]);
expect(r1.since).toMatch(/^\d{4}-\d{2}-\d{2}T.*Z$/);
// Full delta path: assembly reads run fine; the cursor-advance write fails
// silently and the payload is still complete.
const r2 = await call(del, ctx, { session_id: 'outage', since: '1970-01-01T00:00:00Z' });
expect(r2.protocol_version).toBe(1);
expect(Array.isArray(r2.pages)).toBe(true);
expect(r2.since).toBe('1970-01-01T00:00:00.000Z');
expect(r2.next_cursor).toBeTruthy();
// The writes really did fail: nothing persisted for this session.
expect(await getSessionContextState(engine, 'default', null, 'outage')).toBeNull();
});
});
describe('push-path IPC handler (extracted, real engine)', () => {
test('bankOnly persists standing entities under the local lane; assembly merges them back', async () => {
const { makeContextPackIpcHandler } = await import('../src/mcp/context-pack-handler.ts');
const handler = makeContextPackIpcHandler(engine, 'default');
const bank = await handler({
kind: 'context_pack', protocol: 2, secret: 's',
sessionId: 'push-1',
window: [{ role: 'user', text: 'we should follow up with Acme Example about the pilot' }],
bankOnly: true,
});
expect(bank?.text).toBe('');
const st = await getSessionContextState(engine, 'default', null, 'push-1');
expect((st?.standing_entities ?? []).length).toBeGreaterThan(0);
// Assembly path picks the banked set back up (cards may be empty — the
// entities need not resolve — but the merge itself must not throw and the
// cursor must advance on a complete pack).
const before = st?.last_wake_at ?? null;
const res = await handler({ kind: 'context_pack', protocol: 2, secret: 's', sessionId: 'push-1' });
expect(res).not.toBeNull();
const after = await getSessionContextState(engine, 'default', null, 'push-1');
expect(after?.last_wake_at).not.toBe(before); // complete pack advances the cursor
});
test('a deadline-degraded pack does NOT advance the wake cursor', async () => {
const { makeContextPackIpcHandler } = await import('../src/mcp/context-pack-handler.ts');
// Freeze a known cursor + banked entities first (INSERT path) so the
// handler's assembly has real card work to blow the deadline on.
await upsertSessionContextState(engine, 'default', null, 'push-2', {
lastWakeAt: '2026-08-01T00:00:00Z',
standingEntities: ['slow-ent-a', 'slow-ent-b', 'slow-ent-c'],
});
// A proxy engine that delays EVERY method call well past the pack budget
// (arms use engine methods, not just executeRaw).
const DELAY = 250;
const slowEngine = new Proxy(engine, {
get(t, k) {
const v = (t as unknown as Record<string | symbol, unknown>)[k];
if (typeof v === 'function' && k !== 'constructor') {
return async (...a: unknown[]) => {
await new Promise((r) => setTimeout(r, DELAY));
return (v as (...x: unknown[]) => unknown).apply(t, a);
};
}
return v;
},
});
const { assembleContextPack } = await import('../src/core/context/turn-context.ts');
const res = await assembleContextPack(slowEngine as never, {
sourceId: 'default',
entities: ['slow-ent-a', 'slow-ent-b', 'slow-ent-c'],
deadlineMs: 100,
});
expect(res.degradedReason).toBe('deadline');
// Snapshot semantics: the returned arrays must not grow after resolution.
const lenCards = res.cards!.length;
await new Promise((r) => setTimeout(r, 4 * DELAY));
expect(res.cards!.length).toBe(lenCards);
// And the handler skips the cursor advance on a degraded result — proven
// via the real handler against the slow engine. (The handler's own state
// read/write use the REAL engine here so we can assert directly.)
const handler = makeContextPackIpcHandler(slowEngine as never, 'default');
await handler({ kind: 'context_pack', protocol: 2, secret: 's', sessionId: 'push-2' });
const st = await getSessionContextState(engine, 'default', null, 'push-2');
expect(new Date(st!.last_wake_at!).toISOString()).toBe('2026-08-01T00:00:00.000Z');
}, 20_000);
test('banked entities surface as VISIBLE warm-pack content on the next wake (v0.45.7)', async () => {
const { makeContextPackIpcHandler } = await import('../src/mcp/context-pack-handler.ts');
const putPage = operations.find((o) => o.name === 'put_page')!;
// A REAL page the banked window entity resolves to (title + slug-suffix arms).
await call(putPage, ctxFor({ remote: false }), {
slug: 'people/dana-example',
content: '# Dana Example\n\nFounder of widget-co; met at the retreat.',
});
const handler = makeContextPackIpcHandler(engine, 'default');
// PreCompact banking: the window NAMES the seeded page.
const bank = await handler({
kind: 'context_pack', protocol: 2, secret: 's',
sessionId: 'push-vis',
window: [{ role: 'user', text: 'we should sync with Dana Example before the board meeting' }],
bankOnly: true,
});
expect(bank?.text).toBe('');
const st = await getSessionContextState(engine, 'default', null, 'push-vis');
expect(st?.standing_entities ?? []).toContain('Dana Example');
// Post-compaction wake: the banked entity resolves to its page and lands in
// the rendered pack — traceable CONTENT, not merely a cursor advance.
const res = await handler({ kind: 'context_pack', protocol: 2, secret: 's', sessionId: 'push-vis' });
expect(res).not.toBeNull();
expect((res!.cards ?? []).some((c) => c.entity.slug === 'people/dana-example')).toBe(true);
expect(res!.text).toContain('## Standing entities');
expect(res!.text).toContain('people/dana-example');
});
});
describe('hot-memory cache: cross-tier isolation (adversarial P1 regression)', () => {
test('a local include_private call must not warm the cache the remote read hits', async () => {
const local = ctxFor({ remote: false });
await call(remember, local, { fact: 'tier-secret burn detail', provenance: 't', entity: 'tier-acme', visibility: 'private' });
__resetHotMemoryCacheForTests();
// Warm the cache at the trusted-local tier (private facts included).
const warm = await call(contextPack, local, { entities: 'tier-acme', include_private: true });
expect((warm.facts as Array<{ fact: string }>).map((f) => f.fact).join('|')).toContain('tier-secret');
// NO cache reset here — the remote call with the same source/session must
// MISS the local-tier entry (the leak was: same key, served private).
const r = await call(contextPack, ctxFor({ remote: true, clientId: 'c1' }), { entities: 'tier-acme', include_private: true });
expect((r.facts as Array<{ fact: string }>).map((f) => f.fact).join('|')).not.toContain('tier-secret');
});
});
describe('visibility (eng 1A / D2=A): world-only default, fail-closed widen', () => {
beforeEach(async () => {
// seed one world + one private fact about the same entity
const local = ctxFor({ remote: false });
await call(remember, local, { fact: 'acme-example raised a seed round', provenance: 'test', entity: 'acme-example', visibility: 'world' });
await call(remember, local, { fact: 'acme-example secret burn rate detail', provenance: 'test', entity: 'acme-example', visibility: 'private' });
__resetHotMemoryCacheForTests();
});
test('local include_private=true widens facts to include private', async () => {
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example', include_private: true });
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
expect(facts).toContain('secret burn rate');
});
test('local default (no include_private) is world-only', async () => {
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example' });
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
expect(facts).not.toContain('secret burn rate');
});
test('remote caller NEVER widens even with include_private=true (fail-closed)', async () => {
const r = await call(contextPack, ctxFor({ remote: true, clientId: 'c1' }), { entities: 'acme-example', include_private: true });
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
expect(facts).not.toContain('secret burn rate');
});
// v0.45.7 gap closure: `delta` mirrors the same fail-closed ladder — the
// facts arm routes through listFactsSince's visibility filter, not the
// hot-memory tier, so it needs its own pins.
test('delta local default (no include_private) is world-only in facts[] AND text', async () => {
const since = new Date(Date.now() - 5 * 60_000).toISOString();
const r = await call(del, ctxFor({ remote: false }), { since });
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
expect(facts).toContain('raised a seed round');
expect(facts).not.toContain('secret burn rate');
expect(r.text as string).not.toContain('secret burn rate');
});
test('delta local include_private=true widens facts to include private', async () => {
const since = new Date(Date.now() - 5 * 60_000).toISOString();
const r = await call(del, ctxFor({ remote: false }), { since, include_private: true });
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
expect(facts).toContain('secret burn rate');
});
test('delta remote caller NEVER widens even with include_private=true (fail-closed)', async () => {
const since = new Date(Date.now() - 5 * 60_000).toISOString();
const r = await call(del, ctxFor({ remote: true, clientId: 'c1' }), { since, include_private: true });
const facts = (r.facts as Array<{ fact: string }>).map((f) => f.fact).join(' | ');
expect(facts).not.toContain('secret burn rate');
expect(r.text as string).not.toContain('secret burn rate');
});
});
describe('budget packing + drop footer', () => {
beforeEach(async () => {
const local = ctxFor({ remote: false });
for (let i = 0; i < 8; i++) {
await call(remember, local, { fact: `fact number ${i} with some length to consume tokens`, provenance: 'test', visibility: 'world' });
}
__resetHotMemoryCacheForTests();
});
test('tiny budget reports dropped_count and never trims client-side', async () => {
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example', budget_tokens: 5 });
expect(r.budget_tokens).toBe(5);
expect(typeof r.budget_used).toBe('number');
expect(r.dropped_count).toBeGreaterThanOrEqual(0);
});
test('no budget → no footer fields', async () => {
const r = await call(contextPack, ctxFor({ remote: false }), { entities: 'acme-example' });
expect(r.budget_tokens).toBeUndefined();
expect(r.dropped_count).toBeUndefined();
});
test('forced overflow: dropped_count > 0 and budget_used stays within budget_tokens (v0.45.7)', async () => {
const local = ctxFor({ remote: false });
// Long facts (~55 tokens each): even ONE cannot fit the 20-token budget,
// so overflow is GUARANTEED, not merely allowed (the >= 0 escape hatch).
for (let i = 0; i < 5; i++) {
await call(remember, local, {
fact: `overflow filler fact ${i} ${'x'.repeat(200)}`,
provenance: 'test',
visibility: 'world',
});
}
__resetHotMemoryCacheForTests();
// Precondition: the unbudgeted pack actually carries facts to drop.
const full = await call(contextPack, local, { entities: 'acme-example' });
expect((full.facts as unknown[]).length).toBeGreaterThan(0);
const r = await call(contextPack, local, { entities: 'acme-example', budget_tokens: 20 });
expect(r.budget_tokens).toBe(20);
expect(r.dropped_count).toBeGreaterThan(0);
expect(r.budget_used).toBeLessThanOrEqual(20);
expect((r.facts as unknown[]).length).toBeLessThan((full.facts as unknown[]).length);
});
});