Files
gbrain/test/llm-intent-escalation.test.ts
3aa064bcc6 fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554) (#3557)
* fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554)

bunfig.toml's legacy-embedding-preload pins the gateway once at process
start to openai:text-embedding-3-large @ 1536, but resetGateway() wiped
that pin to _config = null. The next test file's beforeAll engine-connect
then reconfigured from the SHIPPED default (zembed-1 @ 1280) before the
preload's per-test beforeEach could restore anything, and every 1536-d
fixture in that file failed with `expected 1280 dimensions, not 1536`.
Which file pairs collided depended on shard bin-packing, so adding ANY
test file reshuffled the mines (this is what blocks #3545).

Fix: the preload registers its config as a reset baseline via a new
test-only seam (__setGatewayResetBaselineForTests); resetGateway() clears
all module state as before, then re-applies the baseline. All 93 existing
resetGateway() call sites get the correct behavior with zero edits.
Production is untouched: nothing in src/ calls resetGateway() or the
setter, so the baseline is never registered outside tests and
resetGateway() still fully unconfigures there.

Five tests genuinely need an unconfigured gateway (no_gateway_config
diagnosis, isAvailable=false, the #2590 cold-gateway path, the registry
builtin-default tier); they switch to the new __unconfigureGatewayForTests.
Two files' hand-rolled "restore the legacy pin in afterAll/finally"
workarounds for this exact bug are now redundant and simplified away.

Guard test (test/ai/gateway-reset-baseline.test.ts) pins the contract:
1536/openai immediately after resetGateway(), transports still cleared,
hard-unconfigure still available.

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

* fix(test): restore preload's GBRAIN_AUDIT_DIR instead of deleting it (#3554 sibling)

Same bug class as the gateway fix in this PR: state set once by a bunfig
preload (audit-dir-preload's scratch GBRAIN_AUDIT_DIR), wiped by one
file's cleanup, blast radius decided by shard bin-packing. In shard 6,
test/minions-shell.test.ts (position 12) unconditionally deleted the var
in afterAll; test/audit/audit-dir-preload.test.ts (position 93) then
found it undefined and failed 3 tests — and every file in between wrote
audit fixtures toward the operator's real ~/.gbrain/audit/.

Fix: capture the prior value at file load and conditionally restore it,
the same inline save/restore pattern 11 sibling files already use.
test/e2e/skill-brain-first.test.ts had the identical unconditional
delete in afterEach; fixed the same way. Sweep of every
`delete process.env.GBRAIN_AUDIT_DIR` in test/ confirms all remaining
sites are conditional restores.

Ordered-pair proof (minions-shell.test.ts then
audit/audit-dir-preload.test.ts, one process): 3 fail on master,
43/43 pass with this fix.

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

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 17:08:27 -07:00

141 lines
5.0 KiB
TypeScript

// Commit 4: LLM intent escalation for cross-modal classification.
//
// Covers:
// - parseModality tolerates trailing punctuation + casing
// - classifyModalityWithLLM happy paths (text / image / both)
// - Fail-open on timeout / parse failure / gateway misconfig
// - hybridSearch escalation gate: fires ONLY when flag on + regex 'text' + ambiguous
// - Cache miss: same query asked twice WITH llm_intent=true makes 2 LLM calls
// (caching is the existing query_cache layer, not a per-process LRU)
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import {
classifyModalityWithLLM,
parseModality,
} from '../src/core/search/llm-intent.ts';
import {
__setChatTransportForTests,
__unconfigureGatewayForTests,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
beforeEach(() => {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'test', ANTHROPIC_API_KEY: 'test' },
});
});
afterEach(() => {
resetGateway();
__setChatTransportForTests(null);
});
describe('parseModality (pure function)', () => {
test('"text" → text', () => {
expect(parseModality('text', 'text')).toBe('text');
});
test('"image" → image', () => {
expect(parseModality('image', 'text')).toBe('image');
});
test('"both" → both', () => {
expect(parseModality('both', 'text')).toBe('both');
});
test('"IMAGE." → image (tolerates trailing punctuation + casing)', () => {
expect(parseModality('IMAGE.', 'text')).toBe('image');
});
test('" text \\n" → text (tolerates whitespace)', () => {
expect(parseModality(' text \n', 'image')).toBe('text');
});
test('"none of the above" → fallback', () => {
expect(parseModality('none of the above', 'text')).toBe('text');
expect(parseModality('none of the above', 'image')).toBe('image');
});
test('empty string → fallback', () => {
expect(parseModality('', 'text')).toBe('text');
});
});
describe('classifyModalityWithLLM — happy path', () => {
test('"any pictures from offsite?" → LLM says image → returns image', async () => {
let chatCalled = 0;
__setChatTransportForTests(async (_opts) => {
chatCalled++;
return {
text: 'image',
blocks: [{ type: 'text', text: 'image' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
};
});
const result = await classifyModalityWithLLM('any pictures from offsite?');
expect(result).toBe('image');
expect(chatCalled).toBe(1);
});
test('"what is founder mode" → LLM says text → returns text', async () => {
__setChatTransportForTests(async () => ({
text: 'text',
blocks: [{ type: 'text', text: 'text' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
}));
expect(await classifyModalityWithLLM('what is founder mode')).toBe('text');
});
test('LLM says "both" → returns both', async () => {
__setChatTransportForTests(async () => ({
text: 'both',
blocks: [{ type: 'text', text: 'both' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
}));
expect(await classifyModalityWithLLM('ambiguous query')).toBe('both');
});
});
describe('classifyModalityWithLLM — fail-open', () => {
test('LLM throws → returns fallback (text)', async () => {
__setChatTransportForTests(async () => {
throw new Error('network error');
});
expect(await classifyModalityWithLLM('q')).toBe('text');
});
test('LLM returns unrecognized output → returns fallback', async () => {
__setChatTransportForTests(async () => ({
text: 'gibberish output',
blocks: [{ type: 'text', text: 'gibberish output' }],
stopReason: 'end',
usage: { input_tokens: 10, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-haiku-4-5',
providerId: 'anthropic',
}));
expect(await classifyModalityWithLLM('q', 'text')).toBe('text');
});
test('Gateway not configured → returns fallback', async () => {
// Hard-unconfigure: resetGateway() would restore the preload's test
// baseline (#3554), whose {...process.env} could make chat available.
__unconfigureGatewayForTests();
// No configureGateway called → isAvailable('chat') returns false.
expect(await classifyModalityWithLLM('q', 'text')).toBe('text');
});
test('Explicit fallback honored', async () => {
__setChatTransportForTests(async () => {
throw new Error('boom');
});
expect(await classifyModalityWithLLM('q', 'image')).toBe('image');
expect(await classifyModalityWithLLM('q', 'both')).toBe('both');
});
});