Files
gbrain/test/resolvers.test.ts
T
2504abe47f v0.35.3.0 fix wave: extract_facts items + git --no-recurse-submodules placement (#1053)
* refactor(mcp): centralize ParamDef→JSON Schema via shared paramDefToSchema

Three duplicate inline mappers existed across the MCP surface:
- src/mcp/tool-defs.ts (stdio MCP buildToolDefs)
- src/commands/serve-http.ts:837 (live HTTP MCP tools/list)
- src/core/minions/tools/brain-allowlist.ts:84 (subagent tool registry)

Each had subtly different items propagation. The HTTP MCP variant dropped
items entirely, leaving extract_facts.entity_hints broken for OAuth-
authenticated remote agents even after a buildToolDefs-only patch. The
subagent variant propagated one level of items but used the same shallow
shape so nested arrays would silently drop.

Extract a single recursive paramDefToSchema helper exported from
src/mcp/tool-defs.ts and have all three mappers consume it. Closes the
bug class at the architecture level instead of patching one site at a
time. The helper copies type, description, enum, default, and recursively
rebuilds items so array-of-arrays preserves inner shape.

Key ordering (type, description, enum, default, items) matches the
pre-v0.34 inline mappers so JSON.stringify output stays byte-stable for
every existing operation that does not use nested arrays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(schema): add items to extract_facts.entity_hints and handle-to-tweet candidates

Two array fields shipped without the items property required by JSON
Schema. Strict-mode validators (Gemini Pro structured outputs, OpenAI
strict tool definitions) reject the entire schema when any type:'array'
lacks items. Downstream agents on those providers couldn't use
extract_facts or the x_handle_to_tweet resolver.

extract_facts.entity_hints — declared items: { type: 'string' } matching
the handler at src/core/operations.ts:2733 which already coerces the
runtime value to string[].

handle_to_tweet outputSchema.candidates — full XTweetCandidate spec
including required + additionalProperties: false. The XTweetCandidate
TypeScript interface declares all five fields as required; without
required in the JSON Schema, a validator would accept {} as a valid
candidate. additionalProperties: false closes the OpenAI strict-mode
contract.

19 community PRs (#1028 #999 #980 #979 #910 #904 #847 #832 #863 #862
#812 for entity_hints; #910 caught candidates) converged on these
locations. This wave cherry-picks the deepest variant (#910 surfaced
both bugs) and centralizes via the paramDefToSchema helper from the
preceding commit so the live HTTP MCP tools/list path is also fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: DmitryBMsk (PR #910)

* fix(git-remote): move --no-recurse-submodules after the subcommand verb

Git CLI accepts two flag positions:
  git [global -c flags] <subcommand> [subcommand flags] [args]

Global -c config flags belong before the verb. Subcommand-specific
flags (like --no-recurse-submodules) belong after. Pre-v0.34
GIT_SSRF_FLAGS spliced both kinds before the verb, so cloneRepo
invoked:
  git -c http.followRedirects=false ... --no-recurse-submodules clone URL DIR

Real git rejects this with exit 129 ("unknown option:
--no-recurse-submodules") because --no-recurse-submodules is a clone
subcommand flag, not a global config flag. Every remote-source clone
broke in production from v0.28 onward. The fake-git harness in
test/git-remote.test.ts exits 0 regardless of argv shape, which is
why CI never caught it.

Split GIT_SSRF_FLAGS (3 -c config flags, spread BEFORE the verb) from
GIT_SSRF_SUBCOMMAND_FLAGS (--no-recurse-submodules, spread AFTER the
verb). cloneRepo and pullRepo both spread the new constant after
their respective verbs. The constant names signal the position rule
so future additions land in the right place.

7 community PRs converged on this location (#1023 #1020 #985 #963
#846 #842#800 doesn't exist). This wave cherry-picks the semantic-
constant approach from #846's GIT_SSRF_SUBCOMMAND_FLAGS name (the
clearest signal of the position rule).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(mcp+git+resolvers): structural array-items + subcommand-position guards

Three new tests / test groups close the bug classes the wave fixes:

test/mcp-tool-defs.test.ts — recursive structural guard walks every
operation's inputSchema and fails with a property path if any
type:'array' lacks items.type. Explicit fixture assertions for
extract_facts.entity_hints.items.type and a synthetic nested-array
ParamDef pinning items.items.type recursion. Without the explicit
fixtures the legacyInlineMap byte-equality test is mirror-theater —
mirroring both sides of the equality preserves the blind spot.

test/git-remote.test.ts — split snapshot test into GIT_SSRF_FLAGS
(3 global -c entries) and GIT_SSRF_SUBCOMMAND_FLAGS
(--no-recurse-submodules). cloneRepo + pullRepo argv tests now assert
the subcommand flag appears AFTER the verb index. Pre-v0.34 the
pinned argv slice prefix included --no-recurse-submodules, which
baked the bug into the test suite (codex catch).

test/resolvers.test.ts — recursive walk over both inputSchema AND
outputSchema for builtin resolvers (xHandleToTweetResolver,
urlReachableResolver). Explicit imports rather than
getDefaultRegistry(), which starts empty until commands/resolvers.ts
runs — codex catch on a hollow-walk failure mode. Dedicated case
pins candidates items shape including required + additionalProperties.

Reference legacyInlineMap in mcp-tool-defs.test.ts mirrors the new
recursive paramDefToSchema helper. No current op uses nested arrays so
the byte-equality test stays green for every existing operation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): raise rerank timeouts for ZE live cold-start

The first rerank call of a CI run hits ZeroEntropy's cold-start latency
(observed ~5-6s on Tier 2 LLM Skills runners; subsequent calls < 500ms).
Two timeouts fired simultaneously at ~5s:

1. bun:test's default 5000ms per-test timeout caused (fail).
2. gateway.rerank's DEFAULT_RERANK_TIMEOUT_MS = 5000 fired right after,
   reported as "Unhandled error between tests".

The next rerank test (top_n=2) ran in 409ms because the API was already
warm. Cold-start is the only issue.

Pass explicit timeoutMs to each rerank() call and a longer per-test
timeout (30s) on both ZE rerank tests. Production DEFAULT_RERANK_TIMEOUT_MS
stays at 5s for the search hot path — these E2E tests bypass it locally
without changing the default that protects user latency.

Unrelated to the fix-wave in this PR (mcp-tool-defs + git-remote + resolver
guards). Lands here to keep Tier 2 LLM Skills green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.35.2.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync for v0.35.2.0

Update CLAUDE.md Key files annotations for the v0.35.2.0 fix wave:

- src/mcp/tool-defs.ts: document new exported recursive paramDefToSchema
  helper and the three-consumer centralization (stdio MCP, HTTP MCP
  tools/list, subagent registry).
- src/core/minions/tools/brain-allowlist.ts: paramsToInputSchema now
  consumes the shared helper.
- src/commands/serve-http.ts: tools/list handler now consumes the shared
  helper (closes the HTTP MCP items-dropped bug class).
- src/core/git-remote.ts: new entry. Documents the GIT_SSRF_FLAGS (global
  config, pre-verb) vs GIT_SSRF_SUBCOMMAND_FLAGS (subcommand-scoped,
  post-verb) split, the 7-month silent regression, and the position-anchored
  regression guard in test/git-remote.test.ts.

Regenerated llms-full.txt to match.

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

* chore: rebump version to v0.35.3.0

Queue moved while this PR was open — v0.35.2.0 was claimed by master's
v0.35.1.0 sibling work. Advancing one slot. No code changes; only:
- VERSION + package.json: 0.35.2.0 → 0.35.3.0
- CHANGELOG.md: rewritten header + inline references
- CLAUDE.md: rewritten 4 key-file annotations
- llms-full.txt + llms.txt: regenerated to mirror CLAUDE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 08:00:29 -07:00

718 lines
25 KiB
TypeScript

/**
* Resolver SDK tests — interface contract + registry + 2 reference builtins.
*
* No network. url_reachable is tested via global fetch mock; x_handle_to_tweet
* via mocked fetch + env. Real-network E2E (if any) lives in test/e2e/.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import {
ResolverRegistry,
ResolverError,
getDefaultRegistry,
_resetDefaultRegistry,
} from '../src/core/resolvers/index.ts';
import type {
Resolver,
ResolverContext,
ResolverRequest,
ResolverResult,
} from '../src/core/resolvers/index.ts';
import { urlReachableResolver, checkDnsRebinding } from '../src/core/resolvers/builtin/url-reachable.ts';
import { xHandleToTweetResolver, computeBackoffMs } from '../src/core/resolvers/builtin/x-api/handle-to-tweet.ts';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeCtx(overrides: Partial<ResolverContext> = {}): ResolverContext {
return {
config: {},
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
requestId: 'test',
remote: false,
...overrides,
};
}
// Tiny fake resolver for contract tests
const echoResolver: Resolver<{ v: string }, { v: string }> = {
id: 'echo',
cost: 'free',
backend: 'local',
description: 'Echo',
async available() { return true; },
async resolve(req: ResolverRequest<{ v: string }>): Promise<ResolverResult<{ v: string }>> {
return {
value: { v: req.input.v },
confidence: 1,
source: 'local',
fetchedAt: new Date(),
};
},
};
// ---------------------------------------------------------------------------
// Registry tests
// ---------------------------------------------------------------------------
describe('ResolverRegistry', () => {
let reg: ResolverRegistry;
beforeEach(() => {
reg = new ResolverRegistry();
});
test('starts empty', () => {
expect(reg.size()).toBe(0);
expect(reg.list()).toEqual([]);
});
test('register + get + has', () => {
reg.register(echoResolver);
expect(reg.size()).toBe(1);
expect(reg.has('echo')).toBe(true);
expect(reg.get('echo').id).toBe('echo');
});
test('register rejects duplicate id', () => {
reg.register(echoResolver);
expect(() => reg.register(echoResolver)).toThrow(ResolverError);
try {
reg.register(echoResolver);
} catch (e) {
expect((e as ResolverError).code).toBe('already_registered');
}
});
test('register rejects empty id', () => {
expect(() => reg.register({ ...echoResolver, id: '' })).toThrow(ResolverError);
});
test('get throws not_found for unknown id', () => {
try {
reg.get('nope');
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ResolverError);
expect((e as ResolverError).code).toBe('not_found');
}
});
test('list returns summaries sorted by id', () => {
reg.register(echoResolver);
reg.register({ ...echoResolver, id: 'alpha' });
const list = reg.list();
expect(list.map(r => r.id)).toEqual(['alpha', 'echo']);
expect(list[0].cost).toBe('free');
expect(list[0].backend).toBe('local');
});
test('list filters by cost', () => {
reg.register(echoResolver); // free
reg.register({ ...echoResolver, id: 'paid-one', cost: 'paid' });
expect(reg.list({ cost: 'paid' }).map(r => r.id)).toEqual(['paid-one']);
expect(reg.list({ cost: 'free' }).map(r => r.id)).toEqual(['echo']);
});
test('list filters by backend', () => {
reg.register(echoResolver);
reg.register({ ...echoResolver, id: 'x-one', backend: 'x-api-v2' });
expect(reg.list({ backend: 'x-api-v2' }).map(r => r.id)).toEqual(['x-one']);
});
test('resolve returns result', async () => {
reg.register(echoResolver);
const r = await reg.resolve('echo', { v: 'hi' }, makeCtx());
expect(r.value).toEqual({ v: 'hi' });
expect(r.confidence).toBe(1);
});
test('resolve throws not_found for unknown id', async () => {
try {
await reg.resolve('nope', {}, makeCtx());
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('not_found');
}
});
test('resolve throws unavailable when available() returns false', async () => {
reg.register({
...echoResolver,
id: 'blocked',
async available() { return false; },
});
try {
await reg.resolve('blocked', {}, makeCtx());
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('unavailable');
}
});
test('clear empties registry', () => {
reg.register(echoResolver);
reg.clear();
expect(reg.size()).toBe(0);
});
});
describe('getDefaultRegistry', () => {
beforeEach(() => _resetDefaultRegistry());
afterEach(() => _resetDefaultRegistry());
test('returns a singleton', () => {
const a = getDefaultRegistry();
const b = getDefaultRegistry();
expect(a).toBe(b);
});
test('_resetDefaultRegistry gives a fresh instance', () => {
const a = getDefaultRegistry();
a.register(echoResolver);
_resetDefaultRegistry();
const b = getDefaultRegistry();
expect(b).not.toBe(a);
expect(b.size()).toBe(0);
});
});
// ---------------------------------------------------------------------------
// url_reachable builtin
// ---------------------------------------------------------------------------
describe('url_reachable resolver', () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
test('available() is true', async () => {
expect(await urlReachableResolver.available(makeCtx())).toBe(true);
});
test('schema: id + cost + backend match contract', () => {
expect(urlReachableResolver.id).toBe('url_reachable');
expect(urlReachableResolver.cost).toBe('free');
expect(urlReachableResolver.backend).toBe('head-check');
});
test('blocks localhost via SSRF guard', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'http://127.0.0.1:1' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.reason).toMatch(/internal|private/i);
});
test('blocks RFC1918 addresses', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'http://10.0.0.1/' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
});
test('blocks AWS metadata endpoint', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'http://169.254.169.254/latest/meta-data/' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
});
test('blocks non-http(s) schemes', async () => {
const r = await urlReachableResolver.resolve({
input: { url: 'file:///etc/passwd' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
});
test('throws schema error for empty url', async () => {
try {
await urlReachableResolver.resolve({ input: { url: '' }, context: makeCtx() });
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('schema');
}
});
test('200 response → reachable=true', async () => {
globalThis.fetch = (async () => new Response('', { status: 200 })) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/ok' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(true);
expect(r.value.status).toBe(200);
});
test('404 response → reachable=false with status + reason', async () => {
globalThis.fetch = (async () => new Response('', { status: 404 })) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/dead' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.status).toBe(404);
expect(r.value.reason).toMatch(/HTTP 404/);
});
test('HEAD 405 falls back to GET', async () => {
let callCount = 0;
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
callCount++;
if (init?.method === 'HEAD') return new Response('', { status: 405 });
return new Response('ok', { status: 200 });
}) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/post-only' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(true);
expect(callCount).toBe(2);
});
test('follows redirect to external URL', async () => {
const responses = [
new Response('', { status: 301, headers: { location: 'https://example.org/final' } }),
new Response('', { status: 200 }),
];
let i = 0;
globalThis.fetch = (async () => responses[i++]) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/redirect' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(true);
expect(r.value.finalUrl).toBe('https://example.org/final');
});
test('blocks redirect to internal URL (per-hop SSRF revalidation)', async () => {
globalThis.fetch = (async () => new Response('', {
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
})) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://example.com/redirects-to-local' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.reason).toMatch(/redirect to blocked/i);
});
test('fetch network failure → reachable=false, confidence=1', async () => {
globalThis.fetch = (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch;
const r = await urlReachableResolver.resolve({
input: { url: 'https://nonexistent.example/' },
context: makeCtx(),
});
expect(r.value.reachable).toBe(false);
expect(r.value.reason).toMatch(/fetch error/);
expect(r.confidence).toBe(1);
});
test('checkDnsRebinding: skips IP literals', async () => {
expect(await checkDnsRebinding('http://8.8.8.8/')).toBeNull();
expect(await checkDnsRebinding('http://127.0.0.1/')).toBeNull();
expect(await checkDnsRebinding('http://[::1]/')).toBeNull();
});
test('checkDnsRebinding: returns null for unparseable URL', async () => {
expect(await checkDnsRebinding('not a url')).toBeNull();
});
test('checkDnsRebinding: returns null on DNS failure (surface via fetch)', async () => {
// Nonexistent TLD; DNS lookup fails, we let the fetch surface the error.
const r = await checkDnsRebinding('http://definitely-not-a-real-tld.invalidtld123/');
expect(r).toBeNull();
});
test('AbortSignal fires mid-flight → ResolverError(aborted)', async () => {
const ac = new AbortController();
globalThis.fetch = (async () => {
const err = new Error('aborted');
err.name = 'AbortError';
throw err;
}) as unknown as typeof fetch;
ac.abort();
try {
await urlReachableResolver.resolve({
input: { url: 'https://example.com/' },
context: makeCtx({ signal: ac.signal }),
});
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ResolverError);
expect((e as ResolverError).code).toBe('aborted');
}
});
});
// ---------------------------------------------------------------------------
// x_handle_to_tweet builtin
// ---------------------------------------------------------------------------
describe('x_handle_to_tweet resolver', () => {
const originalFetch = globalThis.fetch;
const originalToken = process.env.X_API_BEARER_TOKEN;
beforeEach(() => {
delete process.env.X_API_BEARER_TOKEN;
});
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalToken) process.env.X_API_BEARER_TOKEN = originalToken;
else delete process.env.X_API_BEARER_TOKEN;
});
// ---- computeBackoffMs ----
test('computeBackoffMs: honors Retry-After seconds', () => {
const r = new Response('', { status: 429, headers: { 'retry-after': '10' } });
expect(computeBackoffMs(r)).toBe(10_000);
});
test('computeBackoffMs: honors Retry-After HTTP-date', () => {
const now = 1_700_000_000_000; // 2023-11-14T22:13:20Z
const future = new Date(now + 7_000).toUTCString();
const r = new Response('', { status: 429, headers: { 'retry-after': future } });
const ms = computeBackoffMs(r, now);
expect(ms).toBeGreaterThanOrEqual(6_000);
expect(ms).toBeLessThanOrEqual(8_000);
});
test('computeBackoffMs: honors x-rate-limit-reset epoch seconds', () => {
const now = 1_700_000_000_000;
const resetSec = Math.floor(now / 1000) + 15;
const r = new Response('', { status: 429, headers: { 'x-rate-limit-reset': String(resetSec) } });
expect(computeBackoffMs(r, now)).toBeGreaterThanOrEqual(14_000);
expect(computeBackoffMs(r, now)).toBeLessThanOrEqual(16_000);
});
test('computeBackoffMs: takes MAX when both headers present', () => {
const now = 1_700_000_000_000;
const resetSec = Math.floor(now / 1000) + 30;
const r = new Response('', {
status: 429,
headers: { 'retry-after': '5', 'x-rate-limit-reset': String(resetSec) },
});
const ms = computeBackoffMs(r, now);
expect(ms).toBeGreaterThanOrEqual(29_000);
});
test('computeBackoffMs: clamps to floor 2s when no headers', () => {
const r = new Response('', { status: 429 });
expect(computeBackoffMs(r)).toBeGreaterThanOrEqual(2_000);
});
test('computeBackoffMs: clamps to ceiling 60s', () => {
const now = 1_700_000_000_000;
const resetSec = Math.floor(now / 1000) + 600; // 10 min
const r = new Response('', { status: 429, headers: { 'x-rate-limit-reset': String(resetSec) } });
expect(computeBackoffMs(r, now)).toBeLessThanOrEqual(60_000);
});
test('available() false when token missing', async () => {
expect(await xHandleToTweetResolver.available(makeCtx())).toBe(false);
});
test('available() true when token in env', async () => {
process.env.X_API_BEARER_TOKEN = 'fake-token';
expect(await xHandleToTweetResolver.available(makeCtx())).toBe(true);
});
test('available() true when token in ctx.config', async () => {
const ctx = makeCtx({ config: { x_api_bearer_token: 'fake-token' } });
expect(await xHandleToTweetResolver.available(ctx)).toBe(true);
});
test('rejects invalid handle (schema)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'bad handle with spaces' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('schema');
}
});
test('rejects handle longer than 15 chars', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'a'.repeat(16) },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('schema');
}
});
test('throws unavailable when no token at resolve time', async () => {
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('unavailable');
}
});
test('zero candidates → confidence 0', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response(JSON.stringify({ data: [], meta: { result_count: 0 } }), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'nothing matches' },
context: makeCtx(),
});
expect(r.confidence).toBe(0);
expect(r.value.candidates).toEqual([]);
expect(r.value.url).toBeUndefined();
});
test('single strong match → confidence >= 0.8 (auto-repair bucket)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response(JSON.stringify({
data: [
{ id: '123', text: 'talking about building gbrain today', created_at: '2026-04-18T00:00:00Z' },
],
}), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'building gbrain' },
context: makeCtx(),
});
expect(r.confidence).toBeGreaterThanOrEqual(0.8);
expect(r.value.url).toBe('https://x.com/garrytan/status/123');
expect(r.value.tweet_id).toBe('123');
});
test('single weak-match → confidence in 0.5-0.8 review range', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response(JSON.stringify({
data: [{ id: '1', text: 'something unrelated entirely', created_at: '2026-04-18T00:00:00Z' }],
}), { status: 200, headers: { 'content-type': 'application/json' } })) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'gbrain knowledge runtime specific terms' },
context: makeCtx(),
});
expect(r.confidence).toBeGreaterThanOrEqual(0.5);
expect(r.confidence).toBeLessThan(0.8);
});
test('many ambiguous candidates → confidence < 0.5 (skip bucket)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
const data = Array.from({ length: 10 }, (_, i) => ({
id: String(i + 1),
text: 'short noise text ' + i,
created_at: '2026-04-18T00:00:00Z',
}));
globalThis.fetch = (async () => new Response(JSON.stringify({ data }), {
status: 200, headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
const r = await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'completely different signal words unlikely to match' },
context: makeCtx(),
});
expect(r.confidence).toBeLessThan(0.5);
expect(r.value.candidates.length).toBe(10);
expect(r.value.url).toBeUndefined(); // gated by >= 0.5
});
test('401 → ResolverError(auth)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response('unauthorized', { status: 401 })) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('auth');
}
});
test('403 → ResolverError(auth)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response('forbidden', { status: 403 })) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('auth');
}
});
test('500 → ResolverError(upstream) with body snippet', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
globalThis.fetch = (async () => new Response('internal err', { status: 500 })) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('upstream');
expect((e as ResolverError).message).toMatch(/HTTP 500/);
}
});
test('429 retries then surfaces rate_limited', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
let calls = 0;
globalThis.fetch = (async () => {
calls++;
return new Response('rate', { status: 429, headers: { 'retry-after': '0' } });
}) as unknown as typeof fetch;
try {
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan' },
context: makeCtx(),
});
throw new Error('should have thrown');
} catch (e) {
expect((e as ResolverError).code).toBe('rate_limited');
expect(calls).toBeGreaterThanOrEqual(3); // initial + 2 retries
}
});
test('strips X operators from keyword input (injection defense)', async () => {
process.env.X_API_BEARER_TOKEN = 'fake';
let capturedUrl = '';
globalThis.fetch = (async (url: string) => {
capturedUrl = url;
return new Response(JSON.stringify({ data: [] }), {
status: 200, headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
await xHandleToTweetResolver.resolve({
input: { handle: 'garrytan', keywords: 'from:evil_user lang:ja to:someone normal words' },
context: makeCtx(),
});
// Decoded query should still have handle but not extra operators.
// URLSearchParams encodes spaces as '+', so use token-level assertions.
const params = new URL(capturedUrl).searchParams;
const query = params.get('query') ?? '';
expect(query).toContain('from:garrytan');
expect(query).not.toContain('from:evil_user');
expect(query).not.toContain('lang:ja');
expect(query).not.toContain('to:someone');
expect(query).toContain('normal');
expect(query).toContain('words');
});
});
// ---------------------------------------------------------------------------
// Structural array-items guard for resolver inputSchema + outputSchema.
//
// v0.34 fix wave: handle_to_tweet's `candidates` field shipped as bare
// `type: 'array'` without `items` (caught by community PR #910). Same
// bug class as extract_facts.entity_hints, different surface. Resolvers
// don't ride through buildToolDefs so the MCP-side structural guard
// doesn't see them.
//
// This guard walks the builtin resolvers explicitly (NOT via
// getDefaultRegistry(), which starts empty until commands/resolvers.ts
// registers — codex catch). Both inputSchema AND outputSchema are
// checked because resolvers are bidirectional.
// ---------------------------------------------------------------------------
interface SchemaNode {
type?: unknown;
properties?: Record<string, SchemaNode>;
items?: SchemaNode;
[k: string]: unknown;
}
function findArrayWithoutItems(node: SchemaNode | undefined, path: string[]): string[] {
const violations: string[] = [];
if (!node || typeof node !== 'object') return violations;
if (node.type === 'array') {
if (!node.items || typeof node.items !== 'object') {
violations.push(`${path.join('.') || '<root>'} (array missing items)`);
} else if (!('type' in node.items)) {
violations.push(`${path.join('.') || '<root>'}.items (items missing type)`);
} else {
violations.push(...findArrayWithoutItems(node.items, [...path, 'items']));
}
}
if (node.properties && typeof node.properties === 'object') {
for (const [k, child] of Object.entries(node.properties)) {
violations.push(...findArrayWithoutItems(child as SchemaNode, [...path, k]));
}
}
return violations;
}
describe('builtin resolver schemas — structural array-items guard', () => {
// Explicit-import list. getDefaultRegistry() returns an empty registry
// until commands/resolvers.ts registers, so walking it would silently
// pass with zero resolvers visited (codex finding).
const builtins = [
{ name: 'url_reachable', resolver: urlReachableResolver },
{ name: 'x_handle_to_tweet', resolver: xHandleToTweetResolver },
];
for (const { name, resolver } of builtins) {
test(`${name}: inputSchema has no bare arrays`, () => {
const violations = findArrayWithoutItems(
resolver.inputSchema as SchemaNode | undefined,
[name, 'inputSchema'],
);
expect(violations).toEqual([]);
});
test(`${name}: outputSchema has no bare arrays`, () => {
const violations = findArrayWithoutItems(
resolver.outputSchema as SchemaNode | undefined,
[name, 'outputSchema'],
);
expect(violations).toEqual([]);
});
}
test('x_handle_to_tweet candidates declares full item shape with required + additionalProperties:false', () => {
const out = xHandleToTweetResolver.outputSchema as SchemaNode;
const candidates = (out.properties as Record<string, SchemaNode>).candidates;
expect(candidates.type).toBe('array');
expect(candidates.items).toBeDefined();
const items = candidates.items as SchemaNode;
expect(items.type).toBe('object');
expect(items.properties).toBeDefined();
const props = items.properties as Record<string, SchemaNode>;
expect(Object.keys(props).sort()).toEqual(
['created_at', 'score', 'text', 'tweet_id', 'url'],
);
expect((items as { required?: unknown }).required).toEqual(
['tweet_id', 'text', 'created_at', 'score', 'url'],
);
expect((items as { additionalProperties?: unknown }).additionalProperties).toBe(false);
});
test('coverage: at least 2 builtins walked (catches future regression where registry import drops)', () => {
// Negative guard against codex's "underspecified registry walk" finding:
// if this list ever empties (e.g. an autoformatter drops the array),
// the rest of the suite would silently pass.
expect(builtins.length).toBeGreaterThanOrEqual(2);
});
});