mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* 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>
141 lines
5.0 KiB
TypeScript
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');
|
|
});
|
|
});
|