Files
gbrain/test/watch-command.test.ts
T
Garry TanandClaude Fable 5 07d3e45427 fix(context): red-team hardening — pre-cap dedupe, delivery-side reflex logging, window clamp
Four red-team findings on the #2095 push-context surface:

- RT1 starvation: watch's session-dedupe Set filtered AFTER volunteerContext's
  cap, so a recurring already-pushed entity burned cap slots every turn and
  starved fresh pages behind it. VolunteerOpts.excludeSlugs now skips inside
  the pointer loop BEFORE the confidence gate and the cap.
- RT3 honest stats: reflex-channel event logging moved from inside the
  resolver to the DELIVERY point — serve's resolve-IPC onDelivered hook fires
  only after the response write succeeds, and buildReflexAddition logs only
  after the per-turn timeout admits the block. A block the client's 250ms
  budget abandoned was never injected and no longer counts as volunteered.
  (logChannel resolver opt removed; logDeliveredReflexPointers is the seam.)
- RT5 unbounded window: --window-turns is clamped to [1, 64] so a config typo
  can't reintroduce the re-scan-everything-per-turn cost class.
- RT2/RT4 documented + filed: PGLite watch connection monopoly (WATCH_HELP,
  push-context guide, TODO to route watch via serve IPC); host-resolver
  suppression contract at ResolveEntitiesFn (TODO for a capability gate).

Tests: starvation guard (watch + volunteerContext unit), window clamp floor +
ceiling, delivery-side logging (helper writes channel=reflex through the
drained sink; bare resolver writes nothing; empty list no-op), IPC wiring test
rewired to onDelivered. KEY_FILES.md + push-context.md updated; build:llms run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:13:46 -07:00

232 lines
9.2 KiB
TypeScript

/**
* v0.43 (#2095) — `gbrain watch` push transport: streaming loop, rolling
* window, session dedupe, --json shape, event logging on channel 'watch',
* and clean EOF return. Hermetic PGLite + injected line/write deps (no
* subprocess, no real stdin).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runWatch, WATCH_HELP } from '../src/commands/watch.ts';
import { awaitPendingVolunteerEventWrites, _resetPendingVolunteerEventWritesForTests } from '../src/core/context/volunteer-events.ts';
let engine: PGLiteEngine;
async function seed(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* feed(lines: string[]): AsyncGenerator<string> {
for (const l of lines) yield l;
}
async function watchRun(lines: string[], extraArgs: string[] = []): Promise<string[]> {
const out: string[] = [];
await runWatch(engine, ['--source', 'default', ...extraArgs], {
lines: feed(lines),
write: (s) => out.push(s),
isTTY: false,
});
return out;
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
_resetPendingVolunteerEventWritesForTests();
await engine.executeRaw('DELETE FROM context_volunteer_events').catch(() => {});
await engine.executeRaw('DELETE FROM pages');
});
describe('gbrain watch (#2095)', () => {
test('--help prints WATCH_HELP and touches nothing', async () => {
const out = await watchRun([], ['--help']);
expect(out.join('')).toBe(WATCH_HELP);
});
test('volunteers per turn and returns cleanly at EOF', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
const out = await watchRun(['ping Alice Example about the deal']);
const text = out.join('');
expect(text).toContain('people/alice-example');
expect(text).toContain('exact title match');
// runWatch RESOLVED — the EOF → clean-return contract (the entrypoint
// flush-exit + finally drain handle the rest in the real CLI).
});
test('rolling window: assistant-introduced entity fires on the pronoun follow-up turn', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
const out = await watchRun([
'user: who should I ask about the round?',
'assistant: Alice Example led one last year.',
'user: what did she invest in?',
]);
expect(out.join('')).toContain('people/alice-example');
});
test('session dedupe: a slug is volunteered at most once per session', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
const out = await watchRun([
'user: ping Alice Example',
'user: ok',
'user: Alice Example again please',
]);
const hits = out.join('').split('people/alice-example').length - 1;
expect(hits).toBe(1);
});
test('--json emits one JSONL row per volunteered page with turn attribution', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
const out = await watchRun(['user: hello there', 'user: ping Alice Example'], ['--json']);
expect(out.length).toBe(1);
const row = JSON.parse(out[0]);
expect(row.slug).toBe('people/alice-example');
expect(row.turn).toBe(2);
expect(row.arm).toBe('title');
expect(typeof row.confidence).toBe('number');
});
test('events land on channel watch with session_id + turn (drained sink)', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
await watchRun(['user: ping Alice Example']);
const { unfinished } = await awaitPendingVolunteerEventWrites(5_000);
expect(unfinished).toBe(0);
const rows = await engine.executeRaw<{ channel: string; session_id: string; turn: number }>(
`SELECT channel, session_id, turn FROM context_volunteer_events`, [],
);
expect(rows.length).toBe(1);
expect(rows[0].channel).toBe('watch');
expect(rows[0].session_id).toMatch(/^watch-/);
expect(Number(rows[0].turn)).toBe(1);
});
test('min-confidence flag gates exactly like the op', async () => {
await seed('projects/widget-co', 'The Widget Company Project', 'A project.');
const gated = await watchRun(['user: updates on Widget-Co?']);
expect(gated.join('')).toBe('');
const loose = await watchRun(['user: updates on Widget-Co?'], ['--min-confidence', '0.5']);
expect(loose.join('')).toContain('projects/widget-co');
});
test('blank lines and CRLF are tolerated; no turn, no volunteer', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
const out = await watchRun(['', ' ', 'user: ping Alice Example\r']);
expect(out.join('')).toContain('people/alice-example');
});
});
describe('gbrain watch — window + cap flags (ship coverage G4)', () => {
test('--window-turns 1: fires on the mention turn itself; the pronoun follow-up adds nothing', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
const out = await watchRun(
[
'assistant: Alice Example led one last year.',
'user: what did she invest in?',
],
['--window-turns', '1', '--json'],
);
// Watch volunteers per turn: the entity fires at turn 1 (its mention
// turn). With window=1 the follow-up turn extracts nothing, so exactly
// one row exists and it carries turn 1 attribution.
expect(out.length).toBe(1);
expect(JSON.parse(out[0]).turn).toBe(1);
});
test('--max-pages 1 caps a multi-entity turn to one volunteered page', async () => {
await seed('people/alice-example', 'Alice Example', 'Founder.');
await seed('people/bob-sample', 'Bob Sample', 'Engineer.');
const out = await watchRun(
['user: intro Alice Example to Bob Sample'],
['--max-pages', '1', '--json'],
);
expect(out.length).toBe(1);
});
});
describe('gbrain watch — red-team hardening', () => {
test('starvation guard: an already-pushed slug never burns the --max-pages cap slot', async () => {
await seed('people/alice-example', 'Alice Example', 'Founder.');
await seed('people/bob-sample', 'Bob Sample', 'Engineer.');
const out = await watchRun(
[
'user: ping Alice Example about the round',
'user: also intro Alice Example to Bob Sample',
],
['--max-pages', '1', '--json'],
);
// Turn 1: Alice takes the single cap slot. Turn 2: Alice is excluded
// BEFORE the cap (not filtered after), so Bob gets the slot instead of
// the turn volunteering nothing forever.
expect(out.length).toBe(2);
expect(JSON.parse(out[0]).slug).toBe('people/alice-example');
expect(JSON.parse(out[1]).slug).toBe('people/bob-sample');
});
test('--window-turns clamps: 0 and negatives behave as window 1', async () => {
await seed('people/alice-example', 'Alice Example', 'Founder.');
const out = await watchRun(
[
'assistant: Alice Example led one last year.',
'user: what did she invest in?',
],
['--window-turns', '0', '--json'],
);
// Clamped to 1: fires on the mention turn only — identical to the
// --window-turns 1 contract above, not a crash or an unbounded window.
expect(out.length).toBe(1);
expect(JSON.parse(out[0]).turn).toBe(1);
});
test('--window-turns absurdly large still runs (hard cap, no unbounded re-scan)', async () => {
await seed('people/alice-example', 'Alice Example', 'Founder.');
const filler = Array.from({ length: 70 }, (_, i) => `user: filler line ${i}`);
const out = await watchRun(
[...filler, 'user: ping Alice Example'],
['--window-turns', '999999', '--json'],
);
expect(out.length).toBe(1);
expect(JSON.parse(out[0]).slug).toBe('people/alice-example');
});
});
describe('gbrain watch — per-turn fail-open (review hardening)', () => {
test('a transient DB error on one turn never kills the stream', async () => {
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
// Fail the FIRST resolver query against pages (turn 1's resolution);
// everything else — incl. resolveSourceId's pre-loop check — passes.
let pagesQueries = 0;
const flaky = new Proxy(engine, {
get(target, prop, receiver) {
if (prop === 'executeRaw') {
return (sql: string, params: unknown[]) => {
if (/FROM pages/.test(sql) && ++pagesQueries === 1) {
return Promise.reject(new Error('transient db hiccup'));
}
return target.executeRaw(sql, params);
};
}
return Reflect.get(target, prop, receiver);
},
});
const out: string[] = [];
await runWatch(flaky as never, ['--source', 'default'], {
lines: feed(['user: ping Alice Example', 'user: ping Alice Example please']),
write: (s) => out.push(s),
isTTY: false,
});
// Turn 1 failed open; turn 2 volunteered. The stream survived.
expect(out.join('')).toContain('people/alice-example');
});
});