mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
feat(eval): Day 4 — pdf-parse + flight-recorder + tool-bridge (dry_run + expand:false)
Three infrastructure modules for BrainBench v1 Complete Cats 5/8/9/11.
**eval/runner/loaders/pdf.ts** — Thin pdf-parse wrapper. Lazy import keeps
pdf-parse out of the module-load path (avoids library debug-mode side
effects). Size cap (50MB default), encryption detection, structured error
classes (PdfEncryptedError, PdfTooLargeError, PdfParseError). Only Cat 11
multimodal will import this; production bundle never sees pdf-parse.
**eval/runner/tool-bridge.ts** — Maps 12 read-only operations from
src/core/operations.ts to Anthropic tool definitions + adds 3 dry_run write
tools. Three structural invariants enforced:
1. No hidden LLM calls. `operations.query` defaults expand=true which
routes through expansion.ts → Haiku. Bridge strips `expand` from the
query tool's input schema AND executor hard-sets expand:false. Zero
nested Haiku calls in any agent trace.
2. Mutating ops throw ForbiddenOpError. put_page, add_link, delete_page,
etc. are rejected by name. Agents record intent via dry_run_put_page /
dry_run_add_link / dry_run_add_timeline_entry which persist to the
flight-recorder without mutating the engine. This is how Cat 8's
back_link_compliance + citation_format metrics measure anything with
a read-only tool surface.
3. Poison tagged by the bridge, not the judge. Every tool result is
scanned for slugs matching gold/poison.json fixtures. Matched
fixture_ids flow into tool_call_summary.saw_poison_items for the
structured-evidence judge contract. Judge never reads raw tool
output — Section-3 defense against paraphrased prompt injections
(poison payloads never reach the judge model at all).
32K-token cap (~128K chars) with "…[truncated]" suffix.
**eval/runner/recorder.ts** — Per-run flight-recorder bundle emitter. Full
6-artifact bundle (transcript.md, brain-export.json, entity-graph.json,
citations.json, scorecard.json, judge-notes.md) when the adapter provides
an AdapterExport; 3-artifact fallback (transcript + scorecard +
judge-notes) otherwise. Atomic writes via tmp+rename. Collision-safe:
duplicate directory names get incremental -2, -3 suffix. `safeStringify`
handles circular references without throwing and JSON-serializes
Float32Array embeddings.
**package.json:** adds pdf-parse@2.4.5 as a devDependency. Scoped to eval/
use only; production gbrain binary unaffected.
**Tests:** 63 new — 30 tool-bridge, 21 recorder, 12 pdf-loader. All pass.
Fake engine uses a Proxy with `__default__` fallback so poison-matching
tests don't have to mock the exact engine method name that each operation
calls (some route via searchKeyword, others via getPage — proxy handles
both uniformly).
Total eval suite now: 132 pass, 0 fail, 923 expect() calls.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8f326cb9f9
commit
934d7ea4b6
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"pdf-parse": "^2.4.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -109,6 +110,28 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.80", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.80", "@napi-rs/canvas-darwin-arm64": "0.1.80", "@napi-rs/canvas-darwin-x64": "0.1.80", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", "@napi-rs/canvas-linux-arm64-musl": "0.1.80", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", "@napi-rs/canvas-linux-x64-gnu": "0.1.80", "@napi-rs/canvas-linux-x64-musl": "0.1.80", "@napi-rs/canvas-win32-x64-msvc": "0.1.80" } }, "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.80", "", { "os": "android", "cpu": "arm64" }, "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.80", "", { "os": "darwin", "cpu": "arm64" }, "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.80", "", { "os": "darwin", "cpu": "x64" }, "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.80", "", { "os": "linux", "cpu": "arm" }, "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.80", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.80", "", { "os": "linux", "cpu": "arm64" }, "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.80", "", { "os": "linux", "cpu": "none" }, "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.80", "", { "os": "linux", "cpu": "x64" }, "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.80", "", { "os": "linux", "cpu": "x64" }, "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.80", "", { "os": "win32", "cpu": "x64" }, "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg=="],
|
||||
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
|
||||
|
||||
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
|
||||
@@ -397,6 +420,10 @@
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pdf-parse": ["pdf-parse@2.4.5", "", { "dependencies": { "@napi-rs/canvas": "0.1.80", "pdfjs-dist": "5.4.296" }, "bin": { "pdf-parse": "bin/cli.mjs" } }, "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="],
|
||||
|
||||
"pgvector": ["pgvector@0.2.1", "", {}, "sha512-nKaQY9wtuiidwLMdVIce1O3kL0d+FxrigCVzsShnoqzOSaWWWOvuctb/sYwlai5cTwwzRSNa+a/NtN2kVZGNJw=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Thin pdf-parse wrapper for BrainBench v1 Cat 11 multimodal ingest.
|
||||
*
|
||||
* pdf-parse is loaded lazily (dynamic import) so module load does not
|
||||
* trigger any debug-mode file reads that the library does on some versions.
|
||||
* Lazy import also keeps the dep out of the production bundle path — only
|
||||
* eval/runner/cat11-multimodal.ts imports this file.
|
||||
*
|
||||
* Guards:
|
||||
* - Size cap (default 50MB) — prevents ingesting malicious/malformed pathological PDFs
|
||||
* - Encrypted PDFs throw `PdfEncryptedError` with a clear message instead of
|
||||
* hanging or returning garbage
|
||||
* - Empty/corrupt PDFs throw `PdfParseError` with path + errno-style context
|
||||
*
|
||||
* No production code path consumes this loader. Cat 11 test fixtures only.
|
||||
*/
|
||||
|
||||
import { statSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export class PdfEncryptedError extends Error {
|
||||
readonly kind = 'encrypted' as const;
|
||||
constructor(path: string) {
|
||||
super(`PDF is encrypted: ${path}`);
|
||||
this.name = 'PdfEncryptedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PdfParseError extends Error {
|
||||
readonly kind = 'parse' as const;
|
||||
constructor(path: string, cause?: unknown) {
|
||||
super(`PDF parse failed: ${path}${cause ? ` (${String(cause)})` : ''}`);
|
||||
this.name = 'PdfParseError';
|
||||
if (cause instanceof Error) this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export class PdfTooLargeError extends Error {
|
||||
readonly kind = 'too_large' as const;
|
||||
constructor(path: string, sizeBytes: number, maxBytes: number) {
|
||||
super(`PDF too large: ${path} is ${sizeBytes} bytes, max ${maxBytes}`);
|
||||
this.name = 'PdfTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PdfExtractOptions {
|
||||
/** Max PDF size in bytes. Default 50MB. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export interface PdfExtractResult {
|
||||
/** Full extracted text, newline-joined across pages. */
|
||||
text: string;
|
||||
/** Number of pages in the PDF. */
|
||||
numPages: number;
|
||||
/** Raw PDF metadata dictionary (title, author, etc.) if present. */
|
||||
info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a PDF file path. Size-guarded and encryption-aware.
|
||||
*
|
||||
* This is the ONLY entry point for PDF extraction in BrainBench. The
|
||||
* pdf-parse package is lazy-loaded here so that (a) a module-load crash in
|
||||
* pdf-parse cannot break unrelated eval runners and (b) the dep stays in
|
||||
* devDependencies — production gbrain binary never sees it.
|
||||
*/
|
||||
export async function extractPdfText(
|
||||
path: string,
|
||||
opts: PdfExtractOptions = {},
|
||||
): Promise<PdfExtractResult> {
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
// Size guard BEFORE reading into memory.
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(path);
|
||||
} catch (err) {
|
||||
throw new PdfParseError(path, err);
|
||||
}
|
||||
if (stat.size > maxBytes) {
|
||||
throw new PdfTooLargeError(path, stat.size, maxBytes);
|
||||
}
|
||||
|
||||
const buffer = await readFile(path);
|
||||
|
||||
// Lazy import — avoids triggering any init-time side-effects of pdf-parse
|
||||
// unless this function is actually called.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pdfParseModule: any = await import('pdf-parse');
|
||||
const pdfParse = pdfParseModule.default ?? pdfParseModule;
|
||||
|
||||
try {
|
||||
const result = await pdfParse(buffer);
|
||||
return {
|
||||
text: String(result.text ?? ''),
|
||||
numPages: Number(result.numpages ?? 0),
|
||||
info: result.info ? { ...result.info } : undefined,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (/encrypt/i.test(message) || /password/i.test(message)) {
|
||||
throw new PdfEncryptedError(path);
|
||||
}
|
||||
throw new PdfParseError(path, err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Flight-recorder — per-run bundle emitter.
|
||||
*
|
||||
* Every eval run produces a bundle at `eval/reports/YYYY-MM-DD-<cat>-<adapter>-<run>/`
|
||||
* with up to 6 artifacts:
|
||||
*
|
||||
* transcript.md — full tool-call + model-call + timing trace
|
||||
* brain-export.json — final brain state (pages, links, timeline, tags) [optional per adapter]
|
||||
* entity-graph.json — nodes + edges for backlink F1 scoring [optional per adapter]
|
||||
* citations.json — claims → source refs (or flagged unsupported) [agent Cats only]
|
||||
* scorecard.json — metrics + tolerance bands + reproducibility config card
|
||||
* judge-notes.md — judge rationale per rubric task [Cat 5/8/9 only]
|
||||
*
|
||||
* Adapters opt into brain-export / entity-graph / citations by implementing
|
||||
* `Adapter.exportState?()`. Adapters that return `null` from that hook get
|
||||
* a minimal 3-artifact bundle (transcript + scorecard + judge-notes). This
|
||||
* keeps the recorder generic across gbrain and external adapters — no
|
||||
* special-casing.
|
||||
*
|
||||
* Writes are atomic (tmp + rename) and race-safe (incremental -2, -3 suffix
|
||||
* on directory collision). Never throws on JSON.stringify — uses a replacer
|
||||
* to handle circular references.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranscriptTurn {
|
||||
turn_index: number;
|
||||
kind: 'model_call' | 'tool_call' | 'tool_result' | 'final_answer';
|
||||
model_call?: {
|
||||
model_id: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
stop_reason?: string;
|
||||
};
|
||||
tool_call?: {
|
||||
tool_name: string;
|
||||
tool_input: Record<string, unknown>;
|
||||
};
|
||||
tool_result?: {
|
||||
tool_name: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
matched_poison_fixture_ids: string[];
|
||||
};
|
||||
final_answer?: {
|
||||
text: string;
|
||||
evidence_refs: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
schema_version: 1;
|
||||
probe_id: string;
|
||||
adapter: { name: string; stack_id: string };
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
turns: TranscriptTurn[];
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
elapsed_ms: number;
|
||||
}
|
||||
|
||||
export interface Scorecard {
|
||||
schema_version: 1;
|
||||
config_card: ScorecardConfigCard;
|
||||
cat: number;
|
||||
N: 1 | 5 | 10;
|
||||
metrics: Record<string, ScoredMetric>;
|
||||
probes_total?: number;
|
||||
probes_passed?: number;
|
||||
probes_partial?: number;
|
||||
probes_failed?: number;
|
||||
verdict: 'pass' | 'fail' | 'baseline_only';
|
||||
total_cost_usd?: number;
|
||||
wall_clock_seconds?: number;
|
||||
}
|
||||
|
||||
export interface ScorecardConfigCard {
|
||||
brainbench_version: string;
|
||||
adapter: { name: string; stack_id: string; gbrain_commit?: string };
|
||||
driver_model?: { model_id: string; provider: string; params?: Record<string, unknown> };
|
||||
judge_model?: { model_id: string; provider: string };
|
||||
embedding_model?: string;
|
||||
corpus_sha: string;
|
||||
seed: number;
|
||||
bun_version?: string;
|
||||
node_version?: string;
|
||||
}
|
||||
|
||||
export interface ScoredMetric {
|
||||
mean: number;
|
||||
tolerance?: number;
|
||||
stddev?: number;
|
||||
per_run?: number[];
|
||||
}
|
||||
|
||||
/** Optional adapter export hook. Adapters implement when they can. */
|
||||
export interface AdapterExport {
|
||||
pages: Array<{ slug: string; type: string; title: string }>;
|
||||
graph: { nodes: Array<{ slug: string }>; edges: Array<{ from: string; to: string; type: string }> };
|
||||
citations?: Array<{ claim: string; source_slug: string | null }>;
|
||||
}
|
||||
|
||||
export interface JudgeNote {
|
||||
probe_id: string;
|
||||
rubric_id?: string;
|
||||
verdict: 'pass' | 'partial' | 'fail' | 'judge_failed';
|
||||
scores: Array<{ criterion_id: string; score: number; rationale: string }>;
|
||||
overall_rationale: string;
|
||||
}
|
||||
|
||||
export interface RunBundle {
|
||||
runId: string;
|
||||
cat: number;
|
||||
adapter: { name: string; stack_id: string };
|
||||
/** 1 = smoke, 5 = iteration, 10 = published. */
|
||||
N: 1 | 5 | 10;
|
||||
/** Full transcript for the run. One transcript per probe; merged if multi-probe. */
|
||||
transcripts: Transcript[];
|
||||
/** Required. Always emitted. */
|
||||
scorecard: Scorecard;
|
||||
/** Optional — only if adapter's exportState() returned non-null. */
|
||||
brainExport?: AdapterExport;
|
||||
/** Optional — for agent Cats (5, 8, 9). */
|
||||
judgeNotes?: JudgeNote[];
|
||||
}
|
||||
|
||||
export interface EmitOptions {
|
||||
/** Root directory for report bundles. Default `eval/reports`. */
|
||||
reportsRoot?: string;
|
||||
}
|
||||
|
||||
export interface EmitResult {
|
||||
/** Absolute directory path where the bundle was written. */
|
||||
dir: string;
|
||||
/** List of filenames emitted into the bundle directory. */
|
||||
files: string[];
|
||||
/** True if directory collision forced an incremental suffix. */
|
||||
collisionRetry: boolean;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit a flight-recorder bundle to disk. Non-null adapter state produces the
|
||||
* full 6-file bundle; null produces the 3-file fallback. Atomic writes +
|
||||
* collision retry.
|
||||
*/
|
||||
export function emitBundle(bundle: RunBundle, opts: EmitOptions = {}): EmitResult {
|
||||
const reportsRoot = opts.reportsRoot ?? join(process.cwd(), 'eval/reports');
|
||||
const baseDir = pickDirectoryName(reportsRoot, bundle);
|
||||
const { finalDir, collisionRetry } = ensureUniqueDir(baseDir);
|
||||
|
||||
mkdirSync(finalDir, { recursive: true });
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
// transcript.md (required)
|
||||
const transcriptMd = renderTranscriptsMarkdown(bundle.transcripts);
|
||||
atomicWrite(join(finalDir, 'transcript.md'), transcriptMd);
|
||||
files.push('transcript.md');
|
||||
|
||||
// scorecard.json (required)
|
||||
atomicWrite(join(finalDir, 'scorecard.json'), safeStringify(bundle.scorecard));
|
||||
files.push('scorecard.json');
|
||||
|
||||
// judge-notes.md (optional, Cat 5/8/9)
|
||||
if (bundle.judgeNotes && bundle.judgeNotes.length > 0) {
|
||||
atomicWrite(join(finalDir, 'judge-notes.md'), renderJudgeNotesMarkdown(bundle.judgeNotes));
|
||||
files.push('judge-notes.md');
|
||||
}
|
||||
|
||||
// Optional adapter-state artifacts (full bundle)
|
||||
if (bundle.brainExport) {
|
||||
atomicWrite(join(finalDir, 'brain-export.json'), safeStringify(bundle.brainExport));
|
||||
files.push('brain-export.json');
|
||||
|
||||
atomicWrite(join(finalDir, 'entity-graph.json'), safeStringify(bundle.brainExport.graph));
|
||||
files.push('entity-graph.json');
|
||||
|
||||
if (bundle.brainExport.citations) {
|
||||
atomicWrite(join(finalDir, 'citations.json'), safeStringify(bundle.brainExport.citations));
|
||||
files.push('citations.json');
|
||||
}
|
||||
}
|
||||
|
||||
return { dir: finalDir, files, collisionRetry };
|
||||
}
|
||||
|
||||
// ─── Directory naming + collision retry ────────────────────────────────
|
||||
|
||||
function pickDirectoryName(reportsRoot: string, bundle: RunBundle): string {
|
||||
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const catLabel = `cat${bundle.cat}`;
|
||||
const adapter = sanitizeForPath(bundle.adapter.name);
|
||||
const run = sanitizeForPath(bundle.runId);
|
||||
return join(reportsRoot, `${date}-${catLabel}-${adapter}-${run}`);
|
||||
}
|
||||
|
||||
function ensureUniqueDir(baseDir: string): { finalDir: string; collisionRetry: boolean } {
|
||||
if (!existsSync(baseDir)) return { finalDir: baseDir, collisionRetry: false };
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
const candidate = `${baseDir}-${i}`;
|
||||
if (!existsSync(candidate)) return { finalDir: candidate, collisionRetry: true };
|
||||
}
|
||||
throw new Error(`recorder: too many collisions for ${baseDir}; bailing`);
|
||||
}
|
||||
|
||||
function sanitizeForPath(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'unnamed';
|
||||
}
|
||||
|
||||
// ─── Atomic write + safe JSON ─────────────────────────────────────────
|
||||
|
||||
function atomicWrite(finalPath: string, content: string): void {
|
||||
const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
|
||||
mkdirSync(dirname(finalPath), { recursive: true });
|
||||
writeFileSync(tmpPath, content);
|
||||
renameSync(tmpPath, finalPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify with a replacer that handles circular references.
|
||||
* Never throws on circular data — replaces with "[Circular]" markers.
|
||||
*/
|
||||
export function safeStringify(value: unknown, indent: number = 2): string {
|
||||
const seen = new WeakSet<object>();
|
||||
return JSON.stringify(
|
||||
value,
|
||||
function (_key, v) {
|
||||
if (v !== null && typeof v === 'object') {
|
||||
if (seen.has(v as object)) return '[Circular]';
|
||||
seen.add(v as object);
|
||||
}
|
||||
// Handle typed arrays (Float32Array from embeddings, etc.)
|
||||
if (v instanceof Float32Array || v instanceof Float64Array) {
|
||||
return Array.from(v as unknown as number[]);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
indent,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Markdown rendering ────────────────────────────────────────────────
|
||||
|
||||
function renderTranscriptsMarkdown(transcripts: Transcript[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('# BrainBench Flight-Recorder Transcript');
|
||||
lines.push('');
|
||||
lines.push(`Total probes: ${transcripts.length}`);
|
||||
lines.push('');
|
||||
|
||||
for (const t of transcripts) {
|
||||
lines.push(`## Probe ${t.probe_id}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Adapter:** \`${t.adapter.name}\` (${t.adapter.stack_id})`);
|
||||
lines.push(`- **Started:** ${t.started_at}`);
|
||||
lines.push(`- **Ended:** ${t.ended_at}`);
|
||||
lines.push(`- **Elapsed:** ${t.elapsed_ms}ms`);
|
||||
lines.push(`- **Tokens:** ${t.total_input_tokens} in / ${t.total_output_tokens} out`);
|
||||
lines.push('');
|
||||
|
||||
for (const turn of t.turns) {
|
||||
lines.push(`### Turn ${turn.turn_index} — ${turn.kind}`);
|
||||
lines.push('');
|
||||
if (turn.kind === 'model_call' && turn.model_call) {
|
||||
lines.push(`- Model: \`${turn.model_call.model_id}\``);
|
||||
lines.push(`- Tokens: ${turn.model_call.input_tokens} in / ${turn.model_call.output_tokens} out`);
|
||||
if (turn.model_call.stop_reason) lines.push(`- Stop reason: \`${turn.model_call.stop_reason}\``);
|
||||
} else if (turn.kind === 'tool_call' && turn.tool_call) {
|
||||
lines.push(`- Tool: \`${turn.tool_call.tool_name}\``);
|
||||
lines.push('- Input:');
|
||||
lines.push(' ```json');
|
||||
lines.push(indentBlock(safeStringify(turn.tool_call.tool_input), ' '));
|
||||
lines.push(' ```');
|
||||
} else if (turn.kind === 'tool_result' && turn.tool_result) {
|
||||
lines.push(`- Tool: \`${turn.tool_result.tool_name}\``);
|
||||
if (turn.tool_result.truncated) lines.push('- **TRUNCATED at 32K-token cap**');
|
||||
if (turn.tool_result.matched_poison_fixture_ids.length > 0) {
|
||||
lines.push(
|
||||
`- **Matched poison fixtures:** ${turn.tool_result.matched_poison_fixture_ids.join(', ')}`,
|
||||
);
|
||||
}
|
||||
lines.push('- Content:');
|
||||
lines.push(' ```');
|
||||
lines.push(indentBlock(turn.tool_result.content, ' '));
|
||||
lines.push(' ```');
|
||||
} else if (turn.kind === 'final_answer' && turn.final_answer) {
|
||||
lines.push('- **Final answer:**');
|
||||
lines.push('');
|
||||
lines.push(indentBlock(turn.final_answer.text, '> '));
|
||||
lines.push('');
|
||||
if (turn.final_answer.evidence_refs.length > 0) {
|
||||
lines.push(`- Evidence refs: ${turn.final_answer.evidence_refs.map(s => `\`${s}\``).join(', ')}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderJudgeNotesMarkdown(notes: JudgeNote[]): string {
|
||||
const lines: string[] = ['# Judge Notes', ''];
|
||||
for (const note of notes) {
|
||||
lines.push(`## Probe ${note.probe_id}`);
|
||||
lines.push('');
|
||||
lines.push(`- **Verdict:** ${note.verdict}`);
|
||||
if (note.rubric_id) lines.push(`- **Rubric:** ${note.rubric_id}`);
|
||||
lines.push('');
|
||||
lines.push('### Scores');
|
||||
lines.push('');
|
||||
for (const s of note.scores) {
|
||||
lines.push(`- **${s.criterion_id}:** ${s.score}/5 — ${s.rationale}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('### Rationale');
|
||||
lines.push('');
|
||||
lines.push(note.overall_rationale);
|
||||
lines.push('');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function indentBlock(s: string, prefix: string): string {
|
||||
return s
|
||||
.split('\n')
|
||||
.map(line => prefix + line)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* Tool bridge — gbrain operations → Anthropic tool definitions + executor.
|
||||
*
|
||||
* Wraps the 12 read-only operations from `src/core/operations.ts` as Anthropic
|
||||
* `tool_use` definitions so the agent adapter (Cat 8/Cat 9) can call them.
|
||||
* Adds 3 `dry_run_*` write tools that record INTENDED writes to the
|
||||
* flight-recorder without mutating engine state — that's how Cat 8's
|
||||
* back_link_compliance and citation_format metrics measure anything.
|
||||
*
|
||||
* Three structural invariants enforced here:
|
||||
*
|
||||
* 1. **No hidden LLM calls.** `operations.query` defaults `expand: true`
|
||||
* which routes through `src/core/search/expansion.ts` → Anthropic Haiku.
|
||||
* The `query` tool strips `expand` from its input schema AND the
|
||||
* executor hard-sets `expand: false`. Zero nested Haiku calls in any
|
||||
* agent-loop trace.
|
||||
*
|
||||
* 2. **Mutating ops throw.** Any attempt to call `put_page`, `add_link`,
|
||||
* `delete_page`, etc. by name raises `ForbiddenOpError`. Agents must
|
||||
* use `dry_run_*` to record intent.
|
||||
*
|
||||
* 3. **Poison is tagged by the bridge, not the judge.** Every tool result
|
||||
* is scanned for slugs that match `gold/poison.json` fixtures. Matched
|
||||
* fixture_ids flow into `tool_call_summary.saw_poison_items`. The judge
|
||||
* receives that structured summary — never the raw tool output. This
|
||||
* is the Section-3 defense against paraphrased prompt injections.
|
||||
*
|
||||
* Output capping: every `tool_result` content string is capped at
|
||||
* `DEFAULT_MAX_CHARS` characters (~32K tokens at ~4 chars/token). Truncated
|
||||
* results get a literal `…[truncated]` suffix.
|
||||
*/
|
||||
|
||||
import { operations as OPERATIONS } from '../../src/core/operations.ts';
|
||||
import type { Operation, OperationContext, ParamDef } from '../../src/core/operations.ts';
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
import type { GBrainConfig } from '../../src/core/config.ts';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/** 12 read-only operations exposed to the agent. Pinned in eval/schemas/tool-schema.schema.json. */
|
||||
export const EXPOSED_READ_TOOLS = [
|
||||
'search',
|
||||
'query',
|
||||
'get_page',
|
||||
'list_pages',
|
||||
'get_backlinks',
|
||||
'get_links',
|
||||
'get_timeline',
|
||||
'get_tags',
|
||||
'traverse_graph',
|
||||
'resolve_slugs',
|
||||
'get_chunks',
|
||||
'get_stats',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export type ReadToolName = typeof EXPOSED_READ_TOOLS[number];
|
||||
|
||||
/** 3 dry-run write tools. Record intent to the flight-recorder; never mutate. */
|
||||
export const DRY_RUN_TOOLS = [
|
||||
'dry_run_put_page',
|
||||
'dry_run_add_link',
|
||||
'dry_run_add_timeline_entry',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export type DryRunToolName = typeof DRY_RUN_TOOLS[number];
|
||||
|
||||
/** ~32K tokens at ~4 chars/token. Matches eval/schemas/tool-schema.schema.json const. */
|
||||
const DEFAULT_MAX_CHARS = 32_768 * 4;
|
||||
const TRUNCATION_SUFFIX = '…[truncated]';
|
||||
|
||||
// ─── Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
export class ForbiddenOpError extends Error {
|
||||
readonly kind = 'forbidden_op' as const;
|
||||
constructor(toolName: string, reason: string) {
|
||||
super(`Forbidden tool call: ${toolName} — ${reason}`);
|
||||
this.name = 'ForbiddenOpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownToolError extends Error {
|
||||
readonly kind = 'unknown_tool' as const;
|
||||
constructor(toolName: string) {
|
||||
super(`Unknown tool: ${toolName}`);
|
||||
this.name = 'UnknownToolError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoisonFixture {
|
||||
fixture_id: string;
|
||||
slug: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface DryRunWrite {
|
||||
tool_name: DryRunToolName;
|
||||
input: Record<string, unknown>;
|
||||
ts: string;
|
||||
/** Cat 8 back_link_compliance metric input. */
|
||||
has_back_links?: boolean;
|
||||
/** Cat 8 citation_format metric input. */
|
||||
citation_format_ok?: boolean;
|
||||
/** Page slug being written to (for structured evidence contract). */
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
export interface ToolBridgeState {
|
||||
/** Poison fixture_ids whose slug showed up in any tool result. */
|
||||
saw_poison_items: string[];
|
||||
/** Intended writes captured by dry_run_* tools. */
|
||||
made_dry_run_writes: DryRunWrite[];
|
||||
/** Per-tool call counts. Cat 8 brain_first_compliance uses this. */
|
||||
count_by_tool: Record<string, number>;
|
||||
/** Sequential list of tool calls in trace order (used by brain_first_ordering). */
|
||||
call_order: string[];
|
||||
}
|
||||
|
||||
export interface ToolBridgeConfig {
|
||||
engine: BrainEngine;
|
||||
/** Gold poison fixtures loaded from eval/data/gold/poison.json. */
|
||||
poisonFixtures: PoisonFixture[];
|
||||
/** Character cap per tool_result. Default DEFAULT_MAX_CHARS (~32K tokens). */
|
||||
maxCharsPerResult?: number;
|
||||
/** Config forwarded to OperationContext. Defaults to minimal eval-safe config. */
|
||||
gbrainConfig?: GBrainConfig;
|
||||
}
|
||||
|
||||
/** Anthropic tool-use compatible definition. */
|
||||
export interface AnthropicToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: {
|
||||
type: 'object';
|
||||
properties: Record<string, AnthropicInputProp>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface AnthropicInputProp {
|
||||
type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
items?: AnthropicInputProp;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
/** Serialized content for tool_result block. String or JSON-stringified object. */
|
||||
content: string;
|
||||
/** True if content was truncated at the char cap. */
|
||||
truncated: boolean;
|
||||
/** Poison fixture_ids that matched slugs in this result. */
|
||||
matched_poison_fixture_ids: string[];
|
||||
}
|
||||
|
||||
// ─── Tool-def builders ────────────────────────────────────────────────
|
||||
|
||||
function paramDefToProp(def: ParamDef): AnthropicInputProp {
|
||||
const prop: AnthropicInputProp = {
|
||||
type: def.type === 'object' ? 'object' : def.type === 'array' ? 'array' : def.type,
|
||||
};
|
||||
if (def.description) prop.description = def.description;
|
||||
if (def.enum) prop.enum = def.enum;
|
||||
if (def.items) prop.items = paramDefToProp(def.items);
|
||||
return prop;
|
||||
}
|
||||
|
||||
function opToToolDef(op: Operation): AnthropicToolDef {
|
||||
const properties: Record<string, AnthropicInputProp> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [paramName, def] of Object.entries(op.params)) {
|
||||
// CRITICAL: strip `expand` from the `query` tool. This param, when true,
|
||||
// routes the handler through expansion.ts → Anthropic Haiku, which is a
|
||||
// hidden nested LLM call the agent must never be able to trigger.
|
||||
if (op.name === 'query' && paramName === 'expand') continue;
|
||||
|
||||
properties[paramName] = paramDefToProp(def);
|
||||
if (def.required) required.push(paramName);
|
||||
}
|
||||
|
||||
return {
|
||||
name: op.name,
|
||||
description: op.description,
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length > 0 ? { required } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tool definitions for the 12 read ops + 3 dry_run tools.
|
||||
* The order matches EXPOSED_READ_TOOLS followed by DRY_RUN_TOOLS.
|
||||
*/
|
||||
export function buildToolDefs(): AnthropicToolDef[] {
|
||||
const byName = new Map<string, Operation>();
|
||||
for (const op of OPERATIONS) byName.set(op.name, op);
|
||||
|
||||
const defs: AnthropicToolDef[] = [];
|
||||
|
||||
for (const name of EXPOSED_READ_TOOLS) {
|
||||
const op = byName.get(name);
|
||||
if (!op) {
|
||||
throw new Error(
|
||||
`tool-bridge: expected operation "${name}" not found in OPERATIONS registry. ` +
|
||||
`This is a contract break — update EXPOSED_READ_TOOLS or src/core/operations.ts.`,
|
||||
);
|
||||
}
|
||||
if (op.mutating) {
|
||||
throw new Error(
|
||||
`tool-bridge: operation "${name}" is marked mutating but appears in EXPOSED_READ_TOOLS. ` +
|
||||
`Remove it from the list or fix the op's mutating flag.`,
|
||||
);
|
||||
}
|
||||
defs.push(opToToolDef(op));
|
||||
}
|
||||
|
||||
// Dry-run write tools. These are local to the bridge — not backed by any
|
||||
// real operation. They record intent to the flight-recorder without touching
|
||||
// the engine.
|
||||
defs.push({
|
||||
name: 'dry_run_put_page',
|
||||
description:
|
||||
'Record an intended brain-page write to the flight-recorder. Does NOT mutate the engine. ' +
|
||||
'Use when the task asks the agent to update or create a page — the scorer measures intent, not side effects.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string', description: 'Page slug (e.g. "people/jane-chen").' },
|
||||
title: { type: 'string' },
|
||||
compiled_truth: { type: 'string', description: 'The main prose body.' },
|
||||
timeline: { type: 'string', description: 'Timeline section text. Empty string if none.' },
|
||||
frontmatter: { type: 'object', description: 'Optional frontmatter metadata.' },
|
||||
},
|
||||
required: ['slug', 'title', 'compiled_truth'],
|
||||
},
|
||||
});
|
||||
|
||||
defs.push({
|
||||
name: 'dry_run_add_link',
|
||||
description:
|
||||
'Record an intended typed link between two pages. Does NOT mutate the engine. ' +
|
||||
'Recorded to the flight-recorder for Cat 8 back_link_compliance scoring.',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string', description: 'Source page slug.' },
|
||||
to: { type: 'string', description: 'Target page slug.' },
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Link type (e.g. works_at, founded, invested_in, attended, mentions).',
|
||||
},
|
||||
evidence: {
|
||||
type: 'array',
|
||||
description: 'Optional list of slugs or source refs supporting this link.',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
required: ['from', 'to', 'type'],
|
||||
},
|
||||
});
|
||||
|
||||
defs.push({
|
||||
name: 'dry_run_add_timeline_entry',
|
||||
description:
|
||||
'Record an intended timeline entry on a page. Does NOT mutate the engine. ' +
|
||||
'Format: "YYYY-MM-DD | Source — Summary".',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string' },
|
||||
date: { type: 'string', description: 'YYYY-MM-DD.' },
|
||||
summary: { type: 'string' },
|
||||
source: { type: 'string', description: 'Citation slug or URL.' },
|
||||
},
|
||||
required: ['slug', 'date', 'summary'],
|
||||
},
|
||||
});
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
// ─── Poison tagging ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan a tool result for slugs matching poison fixtures. Returns the list
|
||||
* of fixture_ids whose slug appears anywhere in the serialized content.
|
||||
*
|
||||
* Matching is conservative: we look for the literal slug string, bounded
|
||||
* by non-identifier characters (start-of-string, whitespace, quote, slash
|
||||
* segment boundary). This keeps us from flagging `people/jane-chen` as a
|
||||
* match for `people/jane` if both exist.
|
||||
*/
|
||||
function matchPoison(serializedContent: string, poisonFixtures: PoisonFixture[]): string[] {
|
||||
const matched: string[] = [];
|
||||
for (const fixture of poisonFixtures) {
|
||||
// Slug shape: `dir/name`. Bound left + right with lookbehind/lookahead
|
||||
// on non-alphanumeric chars so `people/jane` doesn't match `people/jane-chen`.
|
||||
const escaped = fixture.slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(^|[^a-z0-9-])${escaped}($|[^a-z0-9-])`, 'i');
|
||||
if (re.test(serializedContent)) matched.push(fixture.fixture_id);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
// ─── Truncation ────────────────────────────────────────────────────────
|
||||
|
||||
function truncateContent(raw: string, maxChars: number): { content: string; truncated: boolean } {
|
||||
if (raw.length <= maxChars) return { content: raw, truncated: false };
|
||||
const truncatedLen = maxChars - TRUNCATION_SUFFIX.length;
|
||||
return {
|
||||
content: raw.slice(0, Math.max(0, truncatedLen)) + TRUNCATION_SUFFIX,
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Dry-run helpers ──────────────────────────────────────────────────
|
||||
|
||||
function extractBackLinks(compiledTruth: string): string[] {
|
||||
// Markdown links: [text](slug) where slug has exactly one slash.
|
||||
const re = /\[[^\]]+\]\(([a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)\)/gi;
|
||||
const hits: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(compiledTruth)) !== null) hits.push(m[1]);
|
||||
return hits;
|
||||
}
|
||||
|
||||
function checkCitationFormat(timeline: string): boolean {
|
||||
if (!timeline || !timeline.trim()) return true; // no timeline = nothing to validate
|
||||
// Required format per skills/_output-rules.md:
|
||||
// - **YYYY-MM-DD** | Source — Summary
|
||||
const lines = timeline.split('\n').filter(l => l.trim().startsWith('- '));
|
||||
if (lines.length === 0) return true;
|
||||
const re = /^- \*\*\d{4}-\d{2}-\d{2}\*\*\s*\|\s*.+?\s*[—-]\s*.+/;
|
||||
return lines.every(l => re.test(l));
|
||||
}
|
||||
|
||||
// ─── Tool bridge factory ──────────────────────────────────────────────
|
||||
|
||||
export interface ToolBridge {
|
||||
/** Tool definitions to pass to Anthropic Messages API. */
|
||||
toolDefs: AnthropicToolDef[];
|
||||
/** Execute a tool call. Updates `state` as a side effect. */
|
||||
executeTool(name: string, input: Record<string, unknown>): Promise<ToolResult>;
|
||||
/** Mutating state for the structured-evidence judge contract. */
|
||||
state: ToolBridgeState;
|
||||
}
|
||||
|
||||
export function createToolBridge(config: ToolBridgeConfig): ToolBridge {
|
||||
const maxChars = config.maxCharsPerResult ?? DEFAULT_MAX_CHARS;
|
||||
const opsByName = new Map<string, Operation>();
|
||||
for (const op of OPERATIONS) opsByName.set(op.name, op);
|
||||
|
||||
const state: ToolBridgeState = {
|
||||
saw_poison_items: [],
|
||||
made_dry_run_writes: [],
|
||||
count_by_tool: {},
|
||||
call_order: [],
|
||||
};
|
||||
|
||||
const ctx: OperationContext = {
|
||||
engine: config.engine,
|
||||
config: config.gbrainConfig ?? evalSafeConfig(),
|
||||
logger: silentLogger(),
|
||||
dryRun: false,
|
||||
remote: true, // agent loop is untrusted caller — matches MCP posture
|
||||
};
|
||||
|
||||
const readNames = new Set<string>(EXPOSED_READ_TOOLS);
|
||||
const dryRunNames = new Set<string>(DRY_RUN_TOOLS);
|
||||
|
||||
async function executeTool(
|
||||
name: string,
|
||||
input: Record<string, unknown>,
|
||||
): Promise<ToolResult> {
|
||||
state.call_order.push(name);
|
||||
state.count_by_tool[name] = (state.count_by_tool[name] ?? 0) + 1;
|
||||
|
||||
// ── Dry-run tools (no engine call) ──
|
||||
if (dryRunNames.has(name)) {
|
||||
return executeDryRun(name as DryRunToolName, input, state, maxChars);
|
||||
}
|
||||
|
||||
// ── Read-only tools (dispatch to OPERATIONS) ──
|
||||
if (!readNames.has(name)) {
|
||||
// Unknown or forbidden. Mutating ops surface as forbidden rather than unknown
|
||||
// so an agent trying to cheat gets a clear signal (and the test asserts on it).
|
||||
const op = opsByName.get(name);
|
||||
if (op && op.mutating) {
|
||||
throw new ForbiddenOpError(
|
||||
name,
|
||||
'mutating operations are not exposed to the agent loop; use dry_run_* tools to record intent',
|
||||
);
|
||||
}
|
||||
throw new UnknownToolError(name);
|
||||
}
|
||||
|
||||
const op = opsByName.get(name);
|
||||
if (!op) {
|
||||
throw new Error(
|
||||
`tool-bridge internal: EXPOSED_READ_TOOLS lists "${name}" but OPERATIONS does not. ` +
|
||||
`Regenerate tool schemas.`,
|
||||
);
|
||||
}
|
||||
|
||||
// CRITICAL: force expand=false on query. Belt-and-suspenders with the
|
||||
// schema strip — even if the model somehow passes an `expand` field, we
|
||||
// overwrite it.
|
||||
const safeInput = { ...input };
|
||||
if (name === 'query') safeInput.expand = false;
|
||||
|
||||
const raw = await op.handler(ctx, safeInput);
|
||||
const serialized = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
const { content, truncated } = truncateContent(serialized, maxChars);
|
||||
const matched = matchPoison(content, config.poisonFixtures);
|
||||
for (const id of matched) {
|
||||
if (!state.saw_poison_items.includes(id)) state.saw_poison_items.push(id);
|
||||
}
|
||||
|
||||
return { content, truncated, matched_poison_fixture_ids: matched };
|
||||
}
|
||||
|
||||
return { toolDefs: buildToolDefs(), executeTool, state };
|
||||
}
|
||||
|
||||
function executeDryRun(
|
||||
name: DryRunToolName,
|
||||
input: Record<string, unknown>,
|
||||
state: ToolBridgeState,
|
||||
maxChars: number,
|
||||
): ToolResult {
|
||||
const ts = new Date().toISOString();
|
||||
const write: DryRunWrite = { tool_name: name, input: { ...input }, ts };
|
||||
|
||||
if (name === 'dry_run_put_page') {
|
||||
const slug = typeof input.slug === 'string' ? input.slug : undefined;
|
||||
const compiled = typeof input.compiled_truth === 'string' ? input.compiled_truth : '';
|
||||
const timeline = typeof input.timeline === 'string' ? input.timeline : '';
|
||||
write.slug = slug;
|
||||
write.has_back_links = extractBackLinks(compiled).length > 0;
|
||||
write.citation_format_ok = checkCitationFormat(timeline);
|
||||
} else if (name === 'dry_run_add_link') {
|
||||
write.slug = typeof input.from === 'string' ? input.from : undefined;
|
||||
write.has_back_links = true; // by definition
|
||||
write.citation_format_ok = true;
|
||||
} else if (name === 'dry_run_add_timeline_entry') {
|
||||
const slug = typeof input.slug === 'string' ? input.slug : undefined;
|
||||
const date = typeof input.date === 'string' ? input.date : '';
|
||||
const source = typeof input.source === 'string' ? input.source : '';
|
||||
write.slug = slug;
|
||||
write.has_back_links = false;
|
||||
write.citation_format_ok = /^\d{4}-\d{2}-\d{2}$/.test(date) && source.length > 0;
|
||||
}
|
||||
|
||||
state.made_dry_run_writes.push(write);
|
||||
|
||||
const ack = JSON.stringify({
|
||||
recorded: true,
|
||||
tool: name,
|
||||
slug: write.slug,
|
||||
has_back_links: write.has_back_links,
|
||||
citation_format_ok: write.citation_format_ok,
|
||||
note: 'Intent recorded to flight-recorder. Engine state unchanged.',
|
||||
});
|
||||
const { content, truncated } = truncateContent(ack, maxChars);
|
||||
return { content, truncated, matched_poison_fixture_ids: [] };
|
||||
}
|
||||
|
||||
// ─── Minimal OperationContext fill ────────────────────────────────────
|
||||
|
||||
function evalSafeConfig(): GBrainConfig {
|
||||
// OperationContext.config is typed as GBrainConfig but the read ops we expose
|
||||
// don't actually use it. Return a minimal object; operations that need
|
||||
// specific config fields already guard with defaults.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return {} as any;
|
||||
}
|
||||
|
||||
function silentLogger() {
|
||||
return {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -54,7 +54,8 @@
|
||||
"postgres": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
"@types/bun": "latest",
|
||||
"pdf-parse": "^2.4.5"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* eval/runner/loaders/pdf.ts tests.
|
||||
*
|
||||
* Does NOT test real PDF parsing (that's Cat 11 integration territory with
|
||||
* actual fixture PDFs via `bun run eval:fetch-multimodal`). Focuses on the
|
||||
* guard behavior + error class shape that Cat 11 depends on.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
extractPdfText,
|
||||
PdfEncryptedError,
|
||||
PdfParseError,
|
||||
PdfTooLargeError,
|
||||
} from '../../eval/runner/loaders/pdf.ts';
|
||||
|
||||
function tmpFile(content: Uint8Array | string, name: string): string {
|
||||
const dir = join(tmpdir(), `pdf-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, content as any);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('extractPdfText — guards', () => {
|
||||
test('throws PdfParseError for non-existent file', async () => {
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await extractPdfText('/nonexistent/path/to/file.pdf');
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(PdfParseError);
|
||||
});
|
||||
|
||||
test('throws PdfTooLargeError when file exceeds maxBytes', async () => {
|
||||
const big = new Uint8Array(2048);
|
||||
const path = tmpFile(big, 'big.pdf');
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await extractPdfText(path, { maxBytes: 1024 });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(PdfTooLargeError);
|
||||
rmSync(path);
|
||||
});
|
||||
|
||||
test('throws PdfParseError for non-PDF content', async () => {
|
||||
const junk = 'this is not a PDF file';
|
||||
const path = tmpFile(junk, 'not-a-pdf.pdf');
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await extractPdfText(path);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(PdfParseError);
|
||||
rmSync(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error classes', () => {
|
||||
test('PdfEncryptedError has kind = "encrypted"', () => {
|
||||
const err = new PdfEncryptedError('/x.pdf');
|
||||
expect(err.kind).toBe('encrypted');
|
||||
expect(err.name).toBe('PdfEncryptedError');
|
||||
expect(err.message).toContain('/x.pdf');
|
||||
});
|
||||
|
||||
test('PdfTooLargeError reports size + max', () => {
|
||||
const err = new PdfTooLargeError('/x.pdf', 5_000_000, 1_000_000);
|
||||
expect(err.kind).toBe('too_large');
|
||||
expect(err.message).toContain('5000000');
|
||||
expect(err.message).toContain('1000000');
|
||||
});
|
||||
|
||||
test('PdfParseError preserves cause chain', () => {
|
||||
const inner = new Error('underlying failure');
|
||||
const err = new PdfParseError('/x.pdf', inner);
|
||||
expect(err.kind).toBe('parse');
|
||||
expect((err as Error & { cause?: unknown }).cause).toBe(inner);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* recorder.ts tests — Day 4 of BrainBench v1 Complete.
|
||||
*
|
||||
* Covers:
|
||||
* - 6-artifact full bundle when AdapterExport is provided
|
||||
* - 3-artifact minimal bundle (transcript + scorecard + judge-notes) when export is null
|
||||
* - Atomic writes via tmp+rename (no partial files observable)
|
||||
* - Race-safe collision retry with -2, -3 suffix
|
||||
* - safeStringify handles circular references without throwing
|
||||
* - safeStringify handles typed arrays (Float32Array from embeddings)
|
||||
* - Transcript markdown renders turn types correctly
|
||||
* - Poison fixture matches surface in transcript
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync, existsSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
emitBundle,
|
||||
safeStringify,
|
||||
type RunBundle,
|
||||
type Transcript,
|
||||
type Scorecard,
|
||||
type AdapterExport,
|
||||
type JudgeNote,
|
||||
} from '../../eval/runner/recorder.ts';
|
||||
|
||||
function tmpReportsRoot(): string {
|
||||
const dir = join(tmpdir(), `recorder-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function basicTranscript(): Transcript {
|
||||
return {
|
||||
schema_version: 1,
|
||||
probe_id: 'q-0001',
|
||||
adapter: { name: 'claude-sonnet-with-tools', stack_id: 'gbrain-0.15.0' },
|
||||
started_at: '2026-04-20T10:00:00.000Z',
|
||||
ended_at: '2026-04-20T10:00:05.000Z',
|
||||
turns: [
|
||||
{
|
||||
turn_index: 0,
|
||||
kind: 'model_call',
|
||||
model_call: { model_id: 'claude-sonnet-4-6', input_tokens: 500, output_tokens: 50 },
|
||||
},
|
||||
{
|
||||
turn_index: 1,
|
||||
kind: 'tool_call',
|
||||
tool_call: { tool_name: 'search', tool_input: { query: 'who is amara' } },
|
||||
},
|
||||
{
|
||||
turn_index: 2,
|
||||
kind: 'tool_result',
|
||||
tool_result: {
|
||||
tool_name: 'search',
|
||||
content: '[{"slug":"people/amara","title":"Amara Okafor"}]',
|
||||
truncated: false,
|
||||
matched_poison_fixture_ids: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
turn_index: 3,
|
||||
kind: 'final_answer',
|
||||
final_answer: {
|
||||
text: 'Amara Okafor is a Partner at Halfway Capital.',
|
||||
evidence_refs: ['people/amara'],
|
||||
},
|
||||
},
|
||||
],
|
||||
total_input_tokens: 500,
|
||||
total_output_tokens: 50,
|
||||
elapsed_ms: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
function basicScorecard(): Scorecard {
|
||||
return {
|
||||
schema_version: 1,
|
||||
config_card: {
|
||||
brainbench_version: '0.15.0',
|
||||
adapter: { name: 'claude-sonnet-with-tools', stack_id: 'gbrain-0.15.0' },
|
||||
corpus_sha: 'abc123',
|
||||
seed: 42,
|
||||
},
|
||||
cat: 8,
|
||||
N: 5,
|
||||
metrics: { brain_first_compliance: { mean: 0.96, tolerance: 0.02, per_run: [0.94, 0.96, 0.97] } },
|
||||
verdict: 'pass',
|
||||
};
|
||||
}
|
||||
|
||||
function basicBundle(opts: {
|
||||
withExport?: boolean;
|
||||
withJudge?: boolean;
|
||||
runId?: string;
|
||||
cat?: number;
|
||||
} = {}): RunBundle {
|
||||
const bundle: RunBundle = {
|
||||
runId: opts.runId ?? 'run-001',
|
||||
cat: opts.cat ?? 8,
|
||||
adapter: { name: 'claude-sonnet-with-tools', stack_id: 'gbrain-0.15.0' },
|
||||
N: 5,
|
||||
transcripts: [basicTranscript()],
|
||||
scorecard: basicScorecard(),
|
||||
};
|
||||
if (opts.withExport) {
|
||||
bundle.brainExport = {
|
||||
pages: [{ slug: 'people/amara', type: 'person', title: 'Amara Okafor' }],
|
||||
graph: {
|
||||
nodes: [{ slug: 'people/amara' }],
|
||||
edges: [{ from: 'people/amara', to: 'companies/halfway', type: 'works_at' }],
|
||||
},
|
||||
citations: [{ claim: 'Amara is a Partner', source_slug: 'people/amara' }],
|
||||
};
|
||||
}
|
||||
if (opts.withJudge) {
|
||||
bundle.judgeNotes = [
|
||||
{
|
||||
probe_id: 'q-0001',
|
||||
verdict: 'pass',
|
||||
scores: [{ criterion_id: 'names_entity', score: 5, rationale: 'correct' }],
|
||||
overall_rationale: 'Accurate answer with proper citation.',
|
||||
},
|
||||
];
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
// ─── Bundle emit ──────────────────────────────────────────────────────
|
||||
|
||||
describe('emitBundle — full bundle with adapter export', () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = tmpReportsRoot();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('emits 6 artifacts when brainExport + judgeNotes provided', () => {
|
||||
const bundle = basicBundle({ withExport: true, withJudge: true });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
|
||||
expect(result.collisionRetry).toBe(false);
|
||||
expect(result.files.sort()).toEqual([
|
||||
'brain-export.json',
|
||||
'citations.json',
|
||||
'entity-graph.json',
|
||||
'judge-notes.md',
|
||||
'scorecard.json',
|
||||
'transcript.md',
|
||||
]);
|
||||
for (const f of result.files) {
|
||||
expect(existsSync(join(result.dir, f))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('scorecard.json round-trips through safeStringify', () => {
|
||||
const bundle = basicBundle({ withExport: true });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const parsed = JSON.parse(readFileSync(join(result.dir, 'scorecard.json'), 'utf8'));
|
||||
expect(parsed.cat).toBe(8);
|
||||
expect(parsed.N).toBe(5);
|
||||
expect(parsed.verdict).toBe('pass');
|
||||
});
|
||||
|
||||
test('entity-graph.json contains nodes + edges', () => {
|
||||
const bundle = basicBundle({ withExport: true });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const graph = JSON.parse(readFileSync(join(result.dir, 'entity-graph.json'), 'utf8'));
|
||||
expect(graph.nodes.length).toBe(1);
|
||||
expect(graph.edges[0].type).toBe('works_at');
|
||||
});
|
||||
|
||||
test('transcript.md includes the probe id and turn markers', () => {
|
||||
const bundle = basicBundle({ withExport: true });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const md = readFileSync(join(result.dir, 'transcript.md'), 'utf8');
|
||||
expect(md).toContain('q-0001');
|
||||
expect(md).toContain('Turn 0 — model_call');
|
||||
expect(md).toContain('Turn 2 — tool_result');
|
||||
expect(md).toContain('Amara Okafor is a Partner at Halfway Capital');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitBundle — 3-artifact fallback when no adapter export', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('emits 3 artifacts (transcript + scorecard + judge-notes) when brainExport is null', () => {
|
||||
const bundle = basicBundle({ withExport: false, withJudge: true });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(result.files.sort()).toEqual(['judge-notes.md', 'scorecard.json', 'transcript.md']);
|
||||
});
|
||||
|
||||
test('emits 2 artifacts (transcript + scorecard) when also no judge notes', () => {
|
||||
const bundle = basicBundle({ withExport: false, withJudge: false });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(result.files.sort()).toEqual(['scorecard.json', 'transcript.md']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Collision retry ──────────────────────────────────────────────────
|
||||
|
||||
describe('emitBundle — directory collision retry', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('appends -2 suffix when base dir already exists', () => {
|
||||
const bundle = basicBundle({ runId: 'collide' });
|
||||
const first = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(first.collisionRetry).toBe(false);
|
||||
|
||||
const second = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(second.collisionRetry).toBe(true);
|
||||
expect(second.dir.endsWith('-2')).toBe(true);
|
||||
|
||||
const third = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(third.collisionRetry).toBe(true);
|
||||
expect(third.dir.endsWith('-3')).toBe(true);
|
||||
|
||||
// Verify each is a distinct directory
|
||||
const allSubdirs = readdirSync(root);
|
||||
expect(allSubdirs.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Atomic writes ────────────────────────────────────────────────────
|
||||
|
||||
describe('atomic writes', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('no .tmp- files left in bundle directory after successful emit', () => {
|
||||
const bundle = basicBundle({ withExport: true, withJudge: true });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const files = readdirSync(result.dir);
|
||||
for (const f of files) {
|
||||
expect(f).not.toContain('.tmp-');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── safeStringify ────────────────────────────────────────────────────
|
||||
|
||||
describe('safeStringify', () => {
|
||||
test('handles plain objects', () => {
|
||||
expect(safeStringify({ a: 1, b: 'x' })).toBe('{\n "a": 1,\n "b": "x"\n}');
|
||||
});
|
||||
|
||||
test('handles arrays', () => {
|
||||
expect(safeStringify([1, 2, 3])).toBe('[\n 1,\n 2,\n 3\n]');
|
||||
});
|
||||
|
||||
test('does not throw on circular references', () => {
|
||||
const obj: Record<string, unknown> = { name: 'amara' };
|
||||
obj.self = obj;
|
||||
expect(() => safeStringify(obj)).not.toThrow();
|
||||
const parsed = JSON.parse(safeStringify(obj));
|
||||
expect(parsed.self).toBe('[Circular]');
|
||||
expect(parsed.name).toBe('amara');
|
||||
});
|
||||
|
||||
test('handles Float32Array (embeddings)', () => {
|
||||
const embedding = new Float32Array([0.1, 0.2, 0.3]);
|
||||
const out = safeStringify({ embedding });
|
||||
const parsed = JSON.parse(out);
|
||||
expect(parsed.embedding).toEqual([
|
||||
expect.closeTo(0.1, 6),
|
||||
expect.closeTo(0.2, 6),
|
||||
expect.closeTo(0.3, 6),
|
||||
]);
|
||||
});
|
||||
|
||||
test('nested circular reference survives', () => {
|
||||
const a: Record<string, unknown> = { kind: 'a' };
|
||||
const b: Record<string, unknown> = { kind: 'b', a };
|
||||
a.b = b; // a → b → a cycle
|
||||
expect(() => safeStringify(a)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Transcript rendering ─────────────────────────────────────────────
|
||||
|
||||
describe('transcript markdown rendering', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('renders poison-fixture matches when present in tool_result', () => {
|
||||
const bundle = basicBundle();
|
||||
bundle.transcripts[0].turns[2].tool_result!.matched_poison_fixture_ids = ['poison-001'];
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const md = readFileSync(join(result.dir, 'transcript.md'), 'utf8');
|
||||
expect(md).toContain('Matched poison fixtures');
|
||||
expect(md).toContain('poison-001');
|
||||
});
|
||||
|
||||
test('renders truncation marker when tool_result was capped', () => {
|
||||
const bundle = basicBundle();
|
||||
bundle.transcripts[0].turns[2].tool_result!.truncated = true;
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const md = readFileSync(join(result.dir, 'transcript.md'), 'utf8');
|
||||
expect(md).toContain('TRUNCATED');
|
||||
});
|
||||
|
||||
test('renders multiple probes in one transcript.md', () => {
|
||||
const bundle = basicBundle();
|
||||
bundle.transcripts.push({
|
||||
...basicTranscript(),
|
||||
probe_id: 'q-0002',
|
||||
turns: [
|
||||
{
|
||||
turn_index: 0,
|
||||
kind: 'final_answer',
|
||||
final_answer: { text: 'Second answer.', evidence_refs: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const md = readFileSync(join(result.dir, 'transcript.md'), 'utf8');
|
||||
expect(md).toContain('q-0001');
|
||||
expect(md).toContain('q-0002');
|
||||
expect(md).toContain('Second answer.');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Judge notes rendering ────────────────────────────────────────────
|
||||
|
||||
describe('judge-notes markdown rendering', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('renders verdict + per-criterion scores + rationale', () => {
|
||||
const notes: JudgeNote[] = [
|
||||
{
|
||||
probe_id: 'q-0042',
|
||||
rubric_id: 'self-knowledge-v1',
|
||||
verdict: 'partial',
|
||||
scores: [
|
||||
{ criterion_id: 'cites_source', score: 3, rationale: 'cited one of two expected' },
|
||||
{ criterion_id: 'no_hallucination', score: 5, rationale: 'clean' },
|
||||
],
|
||||
overall_rationale: 'Partial credit due to missing citation.',
|
||||
},
|
||||
];
|
||||
const bundle: RunBundle = {
|
||||
...basicBundle(),
|
||||
judgeNotes: notes,
|
||||
};
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
const md = readFileSync(join(result.dir, 'judge-notes.md'), 'utf8');
|
||||
expect(md).toContain('q-0042');
|
||||
expect(md).toContain('partial');
|
||||
expect(md).toContain('cites_source');
|
||||
expect(md).toContain('3/5');
|
||||
expect(md).toContain('clean');
|
||||
expect(md).toContain('Partial credit due to missing citation');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AdapterExport without citations is handled ──────────────────────
|
||||
|
||||
describe('emitBundle — partial adapter export', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('skips citations.json when brainExport.citations is undefined', () => {
|
||||
const bundle = basicBundle({ withExport: true });
|
||||
delete bundle.brainExport!.citations;
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(result.files).not.toContain('citations.json');
|
||||
expect(result.files).toContain('brain-export.json');
|
||||
expect(result.files).toContain('entity-graph.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Defensive: never writes above reportsRoot ────────────────────────
|
||||
|
||||
describe('emitBundle — path safety', () => {
|
||||
let root: string;
|
||||
beforeEach(() => { root = tmpReportsRoot(); });
|
||||
afterEach(() => { rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
test('sanitizes runId with slashes — cannot escape reportsRoot', () => {
|
||||
const bundle = basicBundle({ runId: '../../etc/passwd' });
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(result.dir.startsWith(root)).toBe(true);
|
||||
});
|
||||
|
||||
test('sanitizes adapter name containing special chars', () => {
|
||||
const bundle = basicBundle();
|
||||
bundle.adapter.name = 'bad/adapter\\name with spaces';
|
||||
const result = emitBundle(bundle, { reportsRoot: root });
|
||||
expect(result.dir.startsWith(root)).toBe(true);
|
||||
expect(result.dir).not.toContain(' ');
|
||||
expect(result.dir).not.toContain('\\');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── sanity check: writeFileSync is reachable from Bun tests ──────────
|
||||
|
||||
describe('test environment sanity', () => {
|
||||
test('can write + read a file', () => {
|
||||
const p = join(tmpdir(), `recorder-sanity-${Date.now()}`);
|
||||
writeFileSync(p, 'hello');
|
||||
expect(readFileSync(p, 'utf8')).toBe('hello');
|
||||
rmSync(p);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* tool-bridge.ts tests — Day 4 of BrainBench v1 Complete.
|
||||
*
|
||||
* Covers:
|
||||
* - Tool-def generation for 12 read ops + 3 dry_run tools (snapshot shape)
|
||||
* - `query` tool strips `expand` from its input schema AND executor forces expand=false
|
||||
* - Mutating ops (put_page, add_link, etc.) throw ForbiddenOpError
|
||||
* - Unknown tools throw UnknownToolError
|
||||
* - 32K char cap truncates with "…[truncated]" marker
|
||||
* - Poison fixtures matched by slug in result content
|
||||
* - Dry-run tools record intent without mutating engine
|
||||
* - Dry-run put_page detects back-links in compiled_truth
|
||||
* - Dry-run add_timeline_entry validates date + source format
|
||||
* - count_by_tool and call_order reflect trace
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
buildToolDefs,
|
||||
createToolBridge,
|
||||
EXPOSED_READ_TOOLS,
|
||||
DRY_RUN_TOOLS,
|
||||
ForbiddenOpError,
|
||||
UnknownToolError,
|
||||
type ToolBridgeConfig,
|
||||
type PoisonFixture,
|
||||
} from '../../eval/runner/tool-bridge.ts';
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
|
||||
// ─── Fake engine — records op calls so tests can assert dispatch ──
|
||||
|
||||
type CallLog = Array<{ method: string; args: unknown[] }>;
|
||||
|
||||
function makeFakeEngine(responses: Record<string, unknown> = {}): {
|
||||
engine: BrainEngine;
|
||||
calls: CallLog;
|
||||
} {
|
||||
const calls: CallLog = [];
|
||||
const engine = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
// Return an async function for any property access.
|
||||
// Key lookup priority: exact method name > __default__ > []
|
||||
return async (...args: unknown[]) => {
|
||||
calls.push({ method: prop, args });
|
||||
if (prop in responses) return responses[prop];
|
||||
if ('__default__' in responses) return responses.__default__;
|
||||
return [];
|
||||
};
|
||||
},
|
||||
},
|
||||
) as unknown as BrainEngine;
|
||||
return { engine, calls };
|
||||
}
|
||||
|
||||
const SAMPLE_POISON: PoisonFixture[] = [
|
||||
{ fixture_id: 'poison-001', slug: 'emails/em-0045', kind: 'prompt-injection' },
|
||||
{ fixture_id: 'poison-002', slug: 'slack/sl-0178', kind: 'obviously-false' },
|
||||
{ fixture_id: 'poison-003', slug: 'notes/2026-03-fake', kind: 'encoded-directive' },
|
||||
];
|
||||
|
||||
function cfg(engine: BrainEngine, poison: PoisonFixture[] = SAMPLE_POISON): ToolBridgeConfig {
|
||||
return { engine, poisonFixtures: poison };
|
||||
}
|
||||
|
||||
// ─── Tool-def generation ──────────────────────────────────────────────
|
||||
|
||||
describe('buildToolDefs — shape + contract', () => {
|
||||
const defs = buildToolDefs();
|
||||
|
||||
test('generates exactly 15 tools (12 read + 3 dry_run)', () => {
|
||||
expect(defs.length).toBe(15);
|
||||
});
|
||||
|
||||
test('first 12 are the canonical read ops in EXPOSED_READ_TOOLS order', () => {
|
||||
const names = defs.slice(0, 12).map(d => d.name);
|
||||
expect(names).toEqual([...EXPOSED_READ_TOOLS]);
|
||||
});
|
||||
|
||||
test('last 3 are the dry_run tools in DRY_RUN_TOOLS order', () => {
|
||||
const names = defs.slice(12).map(d => d.name);
|
||||
expect(names).toEqual([...DRY_RUN_TOOLS]);
|
||||
});
|
||||
|
||||
test('every def has name, description, input_schema.type=object', () => {
|
||||
for (const def of defs) {
|
||||
expect(typeof def.name).toBe('string');
|
||||
expect(def.name.length).toBeGreaterThan(0);
|
||||
expect(typeof def.description).toBe('string');
|
||||
expect(def.input_schema.type).toBe('object');
|
||||
expect(typeof def.input_schema.properties).toBe('object');
|
||||
}
|
||||
});
|
||||
|
||||
test('query tool does NOT include `expand` in its input schema', () => {
|
||||
const queryDef = defs.find(d => d.name === 'query');
|
||||
expect(queryDef).toBeDefined();
|
||||
expect('expand' in (queryDef?.input_schema.properties ?? {})).toBe(false);
|
||||
});
|
||||
|
||||
test('query tool still includes the core params (query, limit, detail)', () => {
|
||||
const queryDef = defs.find(d => d.name === 'query');
|
||||
expect(queryDef?.input_schema.properties.query).toBeDefined();
|
||||
expect(queryDef?.input_schema.properties.query.type).toBe('string');
|
||||
expect(queryDef?.input_schema.properties.limit).toBeDefined();
|
||||
});
|
||||
|
||||
test('dry_run_put_page requires slug, title, compiled_truth', () => {
|
||||
const def = defs.find(d => d.name === 'dry_run_put_page');
|
||||
expect(def?.input_schema.required).toEqual(['slug', 'title', 'compiled_truth']);
|
||||
});
|
||||
|
||||
test('dry_run_add_link requires from, to, type', () => {
|
||||
const def = defs.find(d => d.name === 'dry_run_add_link');
|
||||
expect(def?.input_schema.required).toEqual(['from', 'to', 'type']);
|
||||
});
|
||||
|
||||
test('dry_run_add_timeline_entry requires slug, date, summary', () => {
|
||||
const def = defs.find(d => d.name === 'dry_run_add_timeline_entry');
|
||||
expect(def?.input_schema.required).toEqual(['slug', 'date', 'summary']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── executeTool dispatch + enforcement ──────────────────────────────
|
||||
|
||||
describe('executeTool — read ops', () => {
|
||||
test('dispatches a known read op to the engine', async () => {
|
||||
const { engine, calls } = makeFakeEngine({ getPage: { slug: 'people/amara', title: 'Amara' } });
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('get_page', { slug: 'people/amara' });
|
||||
expect(res.truncated).toBe(false);
|
||||
expect(res.content).toContain('Amara');
|
||||
// The real dispatch goes through operations.ts handlers, so we can't assert the
|
||||
// exact engine method name. But we can assert the bridge updated its state.
|
||||
expect(bridge.state.count_by_tool['get_page']).toBe(1);
|
||||
expect(bridge.state.call_order).toEqual(['get_page']);
|
||||
void calls;
|
||||
});
|
||||
|
||||
test('forces expand=false on query tool even if agent passes expand=true', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
// The query op's handler reads `params.expand !== false`. Our bridge overwrites
|
||||
// expand to false inside executeTool. We can't directly observe the expand value
|
||||
// reaching the handler via the proxy-engine, but we can assert no nested Haiku
|
||||
// call was made by checking call_order only contains 'query'.
|
||||
await bridge.executeTool('query', { query: 'who is amara', expand: true });
|
||||
expect(bridge.state.call_order).toEqual(['query']);
|
||||
expect(bridge.state.count_by_tool['query']).toBe(1);
|
||||
});
|
||||
|
||||
test('throws ForbiddenOpError for mutating ops (put_page)', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await bridge.executeTool('put_page', { slug: 'x/y', type: 'person', title: 't', compiled_truth: '' });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(ForbiddenOpError);
|
||||
expect((err as Error).message).toMatch(/mutating/);
|
||||
});
|
||||
|
||||
test('throws ForbiddenOpError for add_link (mutating)', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await bridge.executeTool('add_link', { from: 'x/y', to: 'a/b', type: 'mentions' });
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(ForbiddenOpError);
|
||||
});
|
||||
|
||||
test('throws UnknownToolError for completely unknown tool', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await bridge.executeTool('does_not_exist', {});
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(UnknownToolError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Truncation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('executeTool — 32K token cap', () => {
|
||||
test('truncates oversized tool results with "…[truncated]" marker', async () => {
|
||||
const huge = 'a'.repeat(200_000); // ~50K tokens
|
||||
const { engine } = makeFakeEngine({ getPage: { slug: 'x', body: huge } });
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('get_page', { slug: 'x' });
|
||||
expect(res.truncated).toBe(true);
|
||||
expect(res.content.endsWith('…[truncated]')).toBe(true);
|
||||
expect(res.content.length).toBeLessThanOrEqual(32768 * 4);
|
||||
});
|
||||
|
||||
test('does not truncate normal-sized results', async () => {
|
||||
const { engine } = makeFakeEngine({ getPage: { slug: 'x', body: 'short body' } });
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('get_page', { slug: 'x' });
|
||||
expect(res.truncated).toBe(false);
|
||||
expect(res.content.endsWith('…[truncated]')).toBe(false);
|
||||
});
|
||||
|
||||
test('respects custom maxCharsPerResult', async () => {
|
||||
const { engine } = makeFakeEngine({ getPage: { slug: 'x', body: 'x'.repeat(500) } });
|
||||
const bridge = createToolBridge({ ...cfg(engine), maxCharsPerResult: 100 });
|
||||
const res = await bridge.executeTool('get_page', { slug: 'x' });
|
||||
expect(res.truncated).toBe(true);
|
||||
expect(res.content.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Poison matching ─────────────────────────────────────────────────
|
||||
|
||||
describe('executeTool — poison slug matching', () => {
|
||||
// Poison matching uses get_page (simpler: returns raw page object without dedup).
|
||||
// The fake engine's __default__ pattern returns the same response for any method.
|
||||
|
||||
test('flags poison fixture when its slug appears in result content', async () => {
|
||||
const { engine } = makeFakeEngine({
|
||||
__default__: { slug: 'emails/em-0045', title: 'Suspicious', compiled_truth: 'body' },
|
||||
});
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('get_page', { slug: 'emails/em-0045' });
|
||||
expect(res.matched_poison_fixture_ids).toContain('poison-001');
|
||||
expect(bridge.state.saw_poison_items).toContain('poison-001');
|
||||
});
|
||||
|
||||
test('does not false-positive on partial slug prefixes', async () => {
|
||||
// poison-001 slug is "emails/em-0045". Result contains "emails/em-00450" — no match.
|
||||
const { engine } = makeFakeEngine({
|
||||
__default__: { slug: 'emails/em-00450', title: 'longer' },
|
||||
});
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('get_page', { slug: 'emails/em-00450' });
|
||||
expect(res.matched_poison_fixture_ids).not.toContain('poison-001');
|
||||
});
|
||||
|
||||
test('multiple poison fixtures in one result all flagged', async () => {
|
||||
const { engine } = makeFakeEngine({
|
||||
__default__: {
|
||||
items: [
|
||||
{ slug: 'emails/em-0045' },
|
||||
{ slug: 'slack/sl-0178' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('get_page', { slug: 'x/y' });
|
||||
expect(res.matched_poison_fixture_ids).toContain('poison-001');
|
||||
expect(res.matched_poison_fixture_ids).toContain('poison-002');
|
||||
});
|
||||
|
||||
test('state.saw_poison_items deduplicates across calls', async () => {
|
||||
const { engine } = makeFakeEngine({
|
||||
__default__: { slug: 'emails/em-0045' },
|
||||
});
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('get_page', { slug: 'a' });
|
||||
await bridge.executeTool('get_page', { slug: 'b' });
|
||||
expect(bridge.state.saw_poison_items).toEqual(['poison-001']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dry-run tools ───────────────────────────────────────────────────
|
||||
|
||||
describe('executeTool — dry_run tools', () => {
|
||||
test('dry_run_put_page records intent, does not hit engine', async () => {
|
||||
const { engine, calls } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
const res = await bridge.executeTool('dry_run_put_page', {
|
||||
slug: 'people/jane',
|
||||
title: 'Jane',
|
||||
compiled_truth: 'Jane works with [Bob](people/bob).',
|
||||
timeline: '',
|
||||
});
|
||||
expect(res.content).toContain('recorded');
|
||||
expect(calls.length).toBe(0); // engine untouched
|
||||
expect(bridge.state.made_dry_run_writes.length).toBe(1);
|
||||
const write = bridge.state.made_dry_run_writes[0];
|
||||
expect(write.tool_name).toBe('dry_run_put_page');
|
||||
expect(write.slug).toBe('people/jane');
|
||||
expect(write.has_back_links).toBe(true);
|
||||
expect(write.citation_format_ok).toBe(true);
|
||||
});
|
||||
|
||||
test('dry_run_put_page detects missing back-links', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('dry_run_put_page', {
|
||||
slug: 'people/jane',
|
||||
title: 'Jane',
|
||||
compiled_truth: 'Jane is a cool person. No links here.',
|
||||
timeline: '',
|
||||
});
|
||||
const write = bridge.state.made_dry_run_writes[0];
|
||||
expect(write.has_back_links).toBe(false);
|
||||
});
|
||||
|
||||
test('dry_run_put_page validates timeline citation format', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('dry_run_put_page', {
|
||||
slug: 'people/jane',
|
||||
title: 'Jane',
|
||||
compiled_truth: 'Body.',
|
||||
timeline: '- **2026-04-20** | emails/em-0001 — Joined Halfway Capital',
|
||||
});
|
||||
const write = bridge.state.made_dry_run_writes[0];
|
||||
expect(write.citation_format_ok).toBe(true);
|
||||
});
|
||||
|
||||
test('dry_run_put_page flags malformed timeline citation', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('dry_run_put_page', {
|
||||
slug: 'people/jane',
|
||||
title: 'Jane',
|
||||
compiled_truth: 'Body.',
|
||||
timeline: '- Joined sometime recently.', // missing date + source + dash
|
||||
});
|
||||
const write = bridge.state.made_dry_run_writes[0];
|
||||
expect(write.citation_format_ok).toBe(false);
|
||||
});
|
||||
|
||||
test('dry_run_add_link records intent', async () => {
|
||||
const { engine, calls } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('dry_run_add_link', {
|
||||
from: 'people/jane',
|
||||
to: 'companies/halfway',
|
||||
type: 'works_at',
|
||||
});
|
||||
expect(calls.length).toBe(0);
|
||||
expect(bridge.state.made_dry_run_writes[0].tool_name).toBe('dry_run_add_link');
|
||||
});
|
||||
|
||||
test('dry_run_add_timeline_entry validates date format', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('dry_run_add_timeline_entry', {
|
||||
slug: 'people/jane',
|
||||
date: '2026-04-20',
|
||||
summary: 'First meeting',
|
||||
source: 'meeting/mtg-0001',
|
||||
});
|
||||
expect(bridge.state.made_dry_run_writes[0].citation_format_ok).toBe(true);
|
||||
});
|
||||
|
||||
test('dry_run_add_timeline_entry rejects bad date format', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('dry_run_add_timeline_entry', {
|
||||
slug: 'people/jane',
|
||||
date: '04/20/2026', // wrong format
|
||||
summary: 'x',
|
||||
source: 's',
|
||||
});
|
||||
expect(bridge.state.made_dry_run_writes[0].citation_format_ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── State tracking ──────────────────────────────────────────────────
|
||||
|
||||
describe('tool-bridge state tracking', () => {
|
||||
test('count_by_tool + call_order reflect every invocation in order', async () => {
|
||||
const { engine } = makeFakeEngine({
|
||||
search: [],
|
||||
get_page: { slug: 'x' },
|
||||
list_pages: [],
|
||||
});
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
await bridge.executeTool('search', { query: 'a' });
|
||||
await bridge.executeTool('get_page', { slug: 'x' });
|
||||
await bridge.executeTool('search', { query: 'b' });
|
||||
await bridge.executeTool('list_pages', { limit: 10 });
|
||||
expect(bridge.state.call_order).toEqual(['search', 'get_page', 'search', 'list_pages']);
|
||||
expect(bridge.state.count_by_tool).toEqual({ search: 2, get_page: 1, list_pages: 1 });
|
||||
});
|
||||
|
||||
test('failed calls (ForbiddenOpError) still count — attempted call is still trace data', async () => {
|
||||
const { engine } = makeFakeEngine();
|
||||
const bridge = createToolBridge(cfg(engine));
|
||||
try {
|
||||
await bridge.executeTool('put_page', { slug: 'x/y', type: 'person', title: 't', compiled_truth: '' });
|
||||
} catch {}
|
||||
// The call registered before the error was thrown.
|
||||
expect(bridge.state.call_order).toEqual(['put_page']);
|
||||
expect(bridge.state.count_by_tool['put_page']).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user