diff --git a/src/cli.ts b/src/cli.ts index 93a36511c..ca519ae8a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -881,7 +881,8 @@ async function makeContext(engine: BrainEngine, params: Record) }; } -function formatResult(opName: string, result: unknown): string { +// Exported for tests (same import-safety contract as cliAliases/printOpHelp). +export function formatResult(opName: string, result: unknown): string { switch (opName) { case 'volunteer_context': { const r = result as any; diff --git a/test/cli-format-volunteer.test.ts b/test/cli-format-volunteer.test.ts new file mode 100644 index 000000000..3e7f24226 --- /dev/null +++ b/test/cli-format-volunteer.test.ts @@ -0,0 +1,67 @@ +/** + * v0.43 (#2095) — formatResult's volunteer_context human rendering + * (ship coverage G3): pointer lines with confidence/arm/rationale, + * the empty-result message, and the approximate stats summary. + */ +import { describe, test, expect } from 'bun:test'; +import { formatResult } from '../src/cli.ts'; + +describe('formatResult — volunteer_context', () => { + test('renders pointer lines with confidence, arm, rationale, and synopsis', () => { + const out = formatResult('volunteer_context', { + pages: [ + { + slug: 'people/alice-example', + source_id: 'default', + display: 'Alice Example', + confidence: 0.85, + arm: 'title', + rationale: 'exact title match "Alice Example"; mentioned in the newest turn', + synopsis: 'Alice is an early founder.', + }, + ], + count: 1, + window_turns: 3, + }); + expect(out).toContain('Alice Example → people/alice-example (0.85, title)'); + expect(out).toContain('exact title match'); + expect(out).toContain('Alice is an early founder.'); + }); + + test('empty result explains the confidence gate', () => { + const out = formatResult('volunteer_context', { pages: [], count: 0, window_turns: 1 }); + expect(out).toContain('Nothing volunteered'); + expect(out).toContain('confidence gate'); + }); + + test('stats mode renders totals, per-arm precision, and the approximate note', () => { + const out = formatResult('volunteer_context', { + days: 30, + approximate: true, + note: 'approximate: "used" = pages.last_retrieved_at > volunteered_at.', + total_volunteered: 4, + total_used: 3, + by_arm: [ + { match_arm: 'alias', channel: 'reflex', volunteered: 2, used: 2, precision: 1 }, + { match_arm: 'title', channel: 'op', volunteered: 2, used: 1, precision: 0.5 }, + ], + }); + expect(out).toContain('last 30 day(s)'); + expect(out).toContain('approximate'); + expect(out).toContain('total: 4 volunteered, 3 used'); + expect(out).toContain('alias/reflex: 2/2 used (precision 1)'); + expect(out).toContain('title/op: 1/2 used (precision 0.5)'); + }); + + test('stats mode with zero events prints the empty-window line', () => { + const out = formatResult('volunteer_context', { + days: 7, + approximate: true, + note: 'approximate', + total_volunteered: 0, + total_used: 0, + by_arm: [], + }); + expect(out).toContain('no volunteer events in the window'); + }); +}); diff --git a/test/watch-command.test.ts b/test/watch-command.test.ts index cc33db249..69278feba 100644 --- a/test/watch-command.test.ts +++ b/test/watch-command.test.ts @@ -153,3 +153,48 @@ describe('gbrain watch — window + cap flags (ship coverage G4)', () => { expect(out.length).toBe(1); }); }); + +describe('gbrain watch — SIGINT lifecycle (real subprocess)', () => { + test('SIGINT mid-stream closes the stream and exits cleanly (drain path, exit 0)', async () => { + const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('fs'); + const { join, resolve } = await import('path'); + const { tmpdir } = await import('os'); + const REPO = resolve(import.meta.dir, '..'); + const home = mkdtempSync(join(tmpdir(), 'gbrain-watch-sigint-')); + try { + mkdirSync(join(home, '.gbrain'), { recursive: true }); + writeFileSync( + join(home, '.gbrain', 'config.json'), + JSON.stringify({ + engine: 'pglite', + database_path: join(home, '.gbrain', 'brain.pglite'), + embedding_dimensions: 1536, + }) + '\n', + ); + // Piped stdin that NEVER reaches EOF — only SIGINT can end the stream. + const proc = Bun.spawn(['bun', 'run', join(REPO, 'src', 'cli.ts'), 'watch'], { + cwd: REPO, + env: { ...process.env, HOME: home, GBRAIN_HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' }, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + proc.stdin.write('user: nothing relevant here\n'); + await proc.stdin.flush(); + // Give the brain time to init + process the turn, then interrupt. + await new Promise((r) => setTimeout(r, 15_000)); + proc.kill('SIGINT'); + const killer = setTimeout(() => { + try { proc.kill('SIGKILL'); } catch { /* already dead */ } + }, 30_000); + const exitCode = await proc.exited; + clearTimeout(killer); + const stderr = await new Response(proc.stderr).text(); + // Clean drain-then-exit: no force-exit banner, no SIGKILL (137), exit 0. + expect(stderr).not.toContain('force-exiting'); + expect(exitCode).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, 120_000); +});