You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Test that judge spend stays out of the eval review cost #107
Move the per-case accumulation in main() of evals/run.ts (the reviewCost sum over outcome.calls, the caseCost over calls with the judge calls, and the pushes into precisions, findingsPerCase and reviewCosts) into an exported pure function, and test that the review cost leaves out the concern-judge and precision-judge calls while the case cost includes them, so eval:compare can't silently report the Opus judge spend as review cost (asked on #73, listed in #96).
Pin two behaviors that only main() functions hold today, so no test reaches them. #107: the evals' review cost must leave out the concern judge's and precision judge's calls while the case cost includes them, or eval:compare reports the Opus judge spend as review cost. #108: a /review explain run must write answer.json (and trace.json only when a call ran), set the answered output, and never write results.json. Each behavior moves, unchanged, into an exported function with tests.
Context for the executor
Build: npm run typecheck
Test: npm test
Conventions: Node 24 runs TypeScript directly; tsconfig.json is strict with noUnusedLocals, so remove an import a step leaves unused. Tests use node:test and node:assert/strict, one behavior per test, hermetic (scratch folders from mkdtempSync(path.join(tmpdir(), ...))). Comments explain why, in plain sentences, above the function. UTF-8, LF.
Gotchas: node/npm may be missing from PATH; in each PowerShell command first run $env:PATH = "C:\Users\Marcin\AppData\Local\nvs\node\26.12.0\x64;$env:PATH". src/main.ts runs main() when imported, so no test may import it; evals/run.ts only runs main() when it is the entry script, so tests import it. Other open pull requests edit src/main.ts line 10 (the ./agent.ts import) and the review part of main(): do not touch those lines.
Decisions
Kind: tests (labels are only follow-up; both titles ask for tests). The extractions are the smallest that let a test reach the behavior; no behavior or log text changes.
Test that judge spend stays out of the eval review cost #107: tallyCase mutates the CaseTally it is given (no I/O, deterministic) rather than returning a new one, so main() keeps one accumulator; saveVerdicts stays in main() because it is I/O.
Test /review explain's answer() directly #108: writeForPublish moves verbatim from src/main.ts to src/outputs.ts (next to assertNoSecrets, which it uses), because the new function in src/explain.ts must call it and src/main.ts can't be imported. teach() and the review keep calling it, now imported.
Test /review explain's answer() directly #108: the new writeAnswer in src/explain.ts takes the ref, the resolution and the ask callback and calls answerFor itself, so a test can show an unknown ref never asks. The two log lines about the call's outcome move into ask in src/main.ts, after the call, so src/main.ts keeps using totalCost and errorText and its ./agent.ts import line stays as it is; the log order is unchanged.
The answered flag is tested through the GITHUB_OUTPUT file, set and restored as test/outcomes.test.ts does around line 566.
Changelog: two ### Changed entries under ## [Unreleased]; no Release: line (release: none).
Out of scope
Any behavior, log text, output file or field change; renames other than those named; formatting churn.
src/main.ts line 10 and the review part of main() (after the teach branch).
Tests of writeForPublish's secret scan (already covered through postAnswer and the publish step).
Change: In evals/run.ts, above const raisedLabel, add an exported interface CaseTally { precisions: CasePrecision[]; findingsPerCase: number[]; reviewCosts: number[] } and export function tallyCase(tally: CaseTally, outcome: { findings: readonly unknown[]; calls: readonly CallRecord[] }, calls: CallRecord[], precision: CasePrecision | undefined): { reviewCost: number; caseCost: number }. Its body, in this order: if (precision) tally.precisions.push(precision);, push outcome.findings.length to tally.findingsPerCase, const reviewCost = outcome.calls.reduce((sum, call) => sum + call.costUsd, 0);, push it to tally.reviewCosts, return { reviewCost, caseCost: totalCost(calls) }. Comment it: it adds one case to the run's tallies; the review cost is only the review's own calls (outcome.calls), while the case cost counts every call in calls, the concern judge's and precision judge's included, so eval:compare never reports the judges' Opus spend as review cost.
In main(): replace the three const precisions/findingsPerCase/reviewCosts lines with const tally: CaseTally = { precisions: [], findingsPerCase: [], reviewCosts: [] };. Replace the lines from if (precision) { through const caseCost = totalCost(calls); with if (precision) saveVerdicts(verdictFile, verdictCache); then const { reviewCost, caseCost } = tallyCase(tally, outcome, calls, precision);. In the summary use totalPrecision(tally.precisions), spread(tally.findingsPerCase), tally.reviewCosts.reduce((a, b) => a + b, 0) and spread(tally.reviewCosts).
In test/smoke-eval.test.ts: add tallyCase, type CaseTally to the ../evals/run.ts import, import type { CallRecord } from '../src/agent.ts'; and type CasePrecision to the ../evals/precision.ts import. Add a helper call(role: string, costUsd: number): CallRecord (provider 'bedrock', model 'm', tokens 0, turns 1, durationMs 1, outcome 'success') and two tests after the casePrecision tests:
tallyCase leaves the concern and precision judges out of the review cost and counts them in the case cost: review calls [call('security', 1), call('synthesis', 0.5)], calls = those plus call('judge', 2) and call('precision-judge', 4); expect { reviewCost: 1.5, caseCost: 7.5 } and tally.reviewCosts[1.5].
tallyCase adds each case's findings and its precision, when it has one, to the tallies: a precision { posted: 1, matched: 1, mustNot: 0, real: 0, notReal: 0, unsure: 0, precision: 1, judged: [] }; tally a case with one finding and that precision, then one with no findings and undefined; expect tally.precisions[precision] and tally.findingsPerCase[1, 0].
Pattern: the casePrecision tests in test/smoke-eval.test.ts.
Done when: npm run typecheck; if ($LASTEXITCODE) { exit 1 }; if (-not (Select-String -Path test/smoke-eval.test.ts -Pattern "^test\('tallyCase" -Quiet)) { exit 1 }; node --test test/smoke-eval.test.ts; exit $LASTEXITCODE
Change: Cut writeForPublish and its one-line comment from src/main.ts and paste them, unchanged except for export, at the end of src/outputs.ts. Add import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; and import path from 'node:path'; at the top of src/outputs.ts (after its header comment). In src/main.ts, change import { assertNoSecrets } from './outputs.ts'; to import { writeForPublish } from './outputs.ts';. Leave src/main.ts's node:fs import as it is (writeStatus and the pause still use it).
Pattern: none.
Done when: npm run typecheck; if ($LASTEXITCODE) { exit 1 }; if (-not (Select-String -Path src/outputs.ts -Pattern '^export function writeForPublish' -Quiet)) { exit 1 }; if (Select-String -Path src/main.ts -Pattern '^function writeForPublish' -Quiet) { exit 1 }; node --test test/explain.test.ts test/teach.test.ts test/pause.test.ts; exit $LASTEXITCODE
Change: In src/explain.ts, import writeForPublish beside assertNoSecrets from ./outputs.ts, and after answerFor add export async function writeAnswer(outDir: string, where: { repository: string; number: number; question: string; secrets: string[] }, ref: FindingRef, resolution: Resolution, ask: (found: FoundFinding) => Promise<AgentResult<Explanation>>): Promise<void>. Body: the code now at the end of answer() in src/main.ts: const { body, explained } = await answerFor(ref, resolution, ask);, the files record with answerFile, then, when explained, files['trace.json'] = { explain: { id: found.id, comment: found.comment.html_url ?? found.comment.id, question: where.question, output: result.output, calls: result.records } }; (no logging), then writeForPublish(outDir, files, where.secrets, 'answered');. Comment: what a /review explain run leaves for the publish step: answer.json, plus trace.json when the agent was asked; an unknown ref writes only the answer, without asking; never results.json.
In src/main.tsanswer(): inside ask, right after the Switched to Bedrock partway line, add the ::warning::The explain call failed line (if (result.error) ...), the const outcome = ... line and the console.log(\${outcome}, $${totalCost(...)...}`)line, moved from the end ofanswer()unchanged. Replace everything fromconst { body, explained } = await answerFor(to the end ofanswer()withawait writeAnswer(outDir, { repository, number, question: command.question, secrets }, command.ref, resolution, ask);. In the ./explain.tsimport dropanswerForandtype Answerand addwriteAnswer`.
In test/explain.test.ts: import writeAnswer, type Resolution, and readdirSync, readFileSync from node:fs. Add a helper answerRun(resolution: Resolution, result: AgentResult<Explanation>) that makes a scratch dir, sets process.env['GITHUB_OUTPUT'] to a file in it (restoring the old value in finally, as test/outcomes.test.ts does near line 566), calls writeAnswer(path.join(dir, 'out'), { repository: 'NCodeGroup/code-review', number: 7, question: 'why?', secrets: ['s'.repeat(20)] }, { kind: 'id', id: 'F3' }, resolution, async (f) => { asked.push(f); return result; }), and returns { outDir, files: readdirSync(outDir).sort(), outputs: <the GITHUB_OUTPUT file's text>, asked }. Tests:
writeAnswer answers an unknown ref with answer.json alone, without asking the agent: { validIds: ['F1'] }; asked[], files['answer.json'], answer.json parses to { repository: 'NCodeGroup/code-review', pullRequest: 7, body: unknownRefBody({ kind: 'id', id: 'F3' }, ['F1']) }.
writeAnswer writes the trace of a call that ran beside the answer: { found } and agentResult({ answer: 'a', holds: true, reason: 'r' }); asked[found], files['answer.json', 'trace.json'], trace.json parses to { explain: { id: 'F3', comment: found.comment.html_url ?? found.comment.id, question: 'why?', output: result.output, calls: result.records } }.
writeAnswer sets the answered output for the publish step: for both resolutions above, outputs is 'answered=true\n'.
writeAnswer never writes results.json, so the publish step posts no review: for both resolutions, files doesn't include 'results.json'.
Pattern: the answerFor and publishAnswer tests in test/explain.test.ts.
Done when: npm run typecheck; if ($LASTEXITCODE) { exit 1 }; if (Select-String -Path src/main.ts -Pattern 'answerFor|trace\.json' -Quiet) { exit 1 }; if (-not (Select-String -Path test/explain.test.ts -Pattern "^test\('writeAnswer" -Quiet)) { exit 1 }; node --test test/explain.test.ts test/pause.test.ts; exit $LASTEXITCODE
Change: Under ## [Unreleased] → ### Changed, after the last entry (the one starting - The `api-compat` floor also reads), add these two lines, each as its own bullet:
- The tests check that the evals' review cost leaves out the concern judge's and precision judge's calls, which the case cost counts, so \eval:compare` never reports the judges' spend as review cost (#107).`
- The tests check what a \/review explain` run leaves for the publish step: only `answer.json` for an unknown finding, without a model call, `trace.json` too when the call ran, the `answered` output, and never `results.json` (#108).`
(Write the backslash-escaped backticks as plain backticks.) Change no other section.
evals/run.tsmain() writes the same summary fields (precision, findingsPerCase, reviewCostUsd, reviewCostPerCase, costUsd) and per-case reviewCostUsd/costUsd as before.
src/main.tsanswer() logs the same lines in the same order, and writes the same answer.json/trace.json with the same answered output; teach() and the review still write through writeForPublish.
Activity
polewskm commentedon Oct 31, 2026
Plan snapshot. The working copy is
PLAN.mdin the plan's checkout; this comment is not updated as steps run.Plan: Test eval review cost and explain's answer (4 steps)
Plan: Test eval review cost and explain's answer
Issue: #107 #108
Goal
Pin two behaviors that only
main()functions hold today, so no test reaches them. #107: the evals' review cost must leave out the concern judge's and precision judge's calls while the case cost includes them, oreval:comparereports the Opus judge spend as review cost. #108: a/review explainrun must writeanswer.json(andtrace.jsononly when a call ran), set theansweredoutput, and never writeresults.json. Each behavior moves, unchanged, into an exported function with tests.Context for the executor
npm run typechecknpm testtsconfig.jsonis strict withnoUnusedLocals, so remove an import a step leaves unused. Tests usenode:testandnode:assert/strict, one behavior per test, hermetic (scratch folders frommkdtempSync(path.join(tmpdir(), ...))). Comments explain why, in plain sentences, above the function. UTF-8, LF.node/npmmay be missing from PATH; in each PowerShell command first run$env:PATH = "C:\Users\Marcin\AppData\Local\nvs\node\26.12.0\x64;$env:PATH".src/main.tsrunsmain()when imported, so no test may import it;evals/run.tsonly runsmain()when it is the entry script, so tests import it. Other open pull requests editsrc/main.tsline 10 (the./agent.tsimport) and the review part ofmain(): do not touch those lines.Decisions
follow-up; both titles ask for tests). The extractions are the smallest that let a test reach the behavior; no behavior or log text changes.tallyCasemutates theCaseTallyit is given (no I/O, deterministic) rather than returning a new one, somain()keeps one accumulator;saveVerdictsstays inmain()because it is I/O.writeForPublishmoves verbatim fromsrc/main.tstosrc/outputs.ts(next toassertNoSecrets, which it uses), because the new function insrc/explain.tsmust call it andsrc/main.tscan't be imported.teach()and the review keep calling it, now imported.writeAnswerinsrc/explain.tstakes the ref, the resolution and theaskcallback and callsanswerForitself, so a test can show an unknown ref never asks. The two log lines about the call's outcome move intoaskinsrc/main.ts, after the call, sosrc/main.tskeeps usingtotalCostanderrorTextand its./agent.tsimport line stays as it is; the log order is unchanged.answeredflag is tested through theGITHUB_OUTPUTfile, set and restored astest/outcomes.test.tsdoes around line 566.### Changedentries under## [Unreleased]; noRelease:line (release: none).Out of scope
src/main.tsline 10 and the review part ofmain()(after theteachbranch).writeForPublish's secret scan (already covered throughpostAnswerand the publish step).Steps
[ ] 1. Extract and test the eval's per-case tally
evals/run.ts(edit),test/smoke-eval.test.ts(edit)evals/run.ts, aboveconst raisedLabel, add an exportedinterface CaseTally { precisions: CasePrecision[]; findingsPerCase: number[]; reviewCosts: number[] }andexport function tallyCase(tally: CaseTally, outcome: { findings: readonly unknown[]; calls: readonly CallRecord[] }, calls: CallRecord[], precision: CasePrecision | undefined): { reviewCost: number; caseCost: number }. Its body, in this order:if (precision) tally.precisions.push(precision);, pushoutcome.findings.lengthtotally.findingsPerCase,const reviewCost = outcome.calls.reduce((sum, call) => sum + call.costUsd, 0);, push it totally.reviewCosts, return{ reviewCost, caseCost: totalCost(calls) }. Comment it: it adds one case to the run's tallies; the review cost is only the review's own calls (outcome.calls), while the case cost counts every call incalls, the concern judge's and precision judge's included, soeval:comparenever reports the judges' Opus spend as review cost.main(): replace the threeconst precisions/findingsPerCase/reviewCostslines withconst tally: CaseTally = { precisions: [], findingsPerCase: [], reviewCosts: [] };. Replace the lines fromif (precision) {throughconst caseCost = totalCost(calls);withif (precision) saveVerdicts(verdictFile, verdictCache);thenconst { reviewCost, caseCost } = tallyCase(tally, outcome, calls, precision);. In the summary usetotalPrecision(tally.precisions),spread(tally.findingsPerCase),tally.reviewCosts.reduce((a, b) => a + b, 0)andspread(tally.reviewCosts).test/smoke-eval.test.ts: addtallyCase, type CaseTallyto the../evals/run.tsimport,import type { CallRecord } from '../src/agent.ts';andtype CasePrecisionto the../evals/precision.tsimport. Add a helpercall(role: string, costUsd: number): CallRecord(provider'bedrock', model'm', tokens 0, turns 1, durationMs 1, outcome'success') and two tests after thecasePrecisiontests:tallyCase leaves the concern and precision judges out of the review cost and counts them in the case cost: review calls[call('security', 1), call('synthesis', 0.5)],calls= those pluscall('judge', 2)andcall('precision-judge', 4); expect{ reviewCost: 1.5, caseCost: 7.5 }andtally.reviewCosts[1.5].tallyCase adds each case's findings and its precision, when it has one, to the tallies: a precision{ posted: 1, matched: 1, mustNot: 0, real: 0, notReal: 0, unsure: 0, precision: 1, judged: [] }; tally a case with one finding and that precision, then one with no findings andundefined; expecttally.precisions[precision]andtally.findingsPerCase[1, 0].casePrecisiontests intest/smoke-eval.test.ts.npm run typecheck; if ($LASTEXITCODE) { exit 1 }; if (-not (Select-String -Path test/smoke-eval.test.ts -Pattern "^test\('tallyCase" -Quiet)) { exit 1 }; node --test test/smoke-eval.test.ts; exit $LASTEXITCODE[ ] 2. Move writeForPublish to outputs.ts
src/outputs.ts(edit),src/main.ts(edit)writeForPublishand its one-line comment fromsrc/main.tsand paste them, unchanged except forexport, at the end ofsrc/outputs.ts. Addimport { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';andimport path from 'node:path';at the top ofsrc/outputs.ts(after its header comment). Insrc/main.ts, changeimport { assertNoSecrets } from './outputs.ts';toimport { writeForPublish } from './outputs.ts';. Leavesrc/main.ts'snode:fsimport as it is (writeStatusand the pause still use it).npm run typecheck; if ($LASTEXITCODE) { exit 1 }; if (-not (Select-String -Path src/outputs.ts -Pattern '^export function writeForPublish' -Quiet)) { exit 1 }; if (Select-String -Path src/main.ts -Pattern '^function writeForPublish' -Quiet) { exit 1 }; node --test test/explain.test.ts test/teach.test.ts test/pause.test.ts; exit $LASTEXITCODE[ ] 3. Extract and test writeAnswer
src/explain.ts(edit),src/main.ts(edit),test/explain.test.ts(edit)src/explain.ts, importwriteForPublishbesideassertNoSecretsfrom./outputs.ts, and afteranswerForaddexport async function writeAnswer(outDir: string, where: { repository: string; number: number; question: string; secrets: string[] }, ref: FindingRef, resolution: Resolution, ask: (found: FoundFinding) => Promise<AgentResult<Explanation>>): Promise<void>. Body: the code now at the end ofanswer()insrc/main.ts:const { body, explained } = await answerFor(ref, resolution, ask);, thefilesrecord withanswerFile, then, whenexplained,files['trace.json'] = { explain: { id: found.id, comment: found.comment.html_url ?? found.comment.id, question: where.question, output: result.output, calls: result.records } };(no logging), thenwriteForPublish(outDir, files, where.secrets, 'answered');. Comment: what a/review explainrun leaves for the publish step:answer.json, plustrace.jsonwhen the agent was asked; an unknown ref writes only the answer, without asking; neverresults.json.src/main.tsanswer(): insideask, right after theSwitched to Bedrock partwayline, add the::warning::The explain call failedline (if (result.error) ...), theconst outcome = ...line and theconsole.log(\line, moved from the end ofanswer()unchanged. Replace everything fromconst { body, explained } = await answerFor(to the end ofanswer()withawait writeAnswer(outDir, { repository, number, question: command.question, secrets }, command.ref, resolution, ask);. In the./explain.tsimport dropanswerForandtype Answerand addwriteAnswer`.test/explain.test.ts: importwriteAnswer,type Resolution, andreaddirSync, readFileSyncfromnode:fs. Add a helperanswerRun(resolution: Resolution, result: AgentResult<Explanation>)that makes a scratch dir, setsprocess.env['GITHUB_OUTPUT']to a file in it (restoring the old value infinally, astest/outcomes.test.tsdoes near line 566), callswriteAnswer(path.join(dir, 'out'), { repository: 'NCodeGroup/code-review', number: 7, question: 'why?', secrets: ['s'.repeat(20)] }, { kind: 'id', id: 'F3' }, resolution, async (f) => { asked.push(f); return result; }), and returns{ outDir, files: readdirSync(outDir).sort(), outputs: <the GITHUB_OUTPUT file's text>, asked }. Tests:writeAnswer answers an unknown ref with answer.json alone, without asking the agent:{ validIds: ['F1'] };asked[],files['answer.json'], answer.json parses to{ repository: 'NCodeGroup/code-review', pullRequest: 7, body: unknownRefBody({ kind: 'id', id: 'F3' }, ['F1']) }.writeAnswer writes the trace of a call that ran beside the answer:{ found }andagentResult({ answer: 'a', holds: true, reason: 'r' });asked[found],files['answer.json', 'trace.json'], trace.json parses to{ explain: { id: 'F3', comment: found.comment.html_url ?? found.comment.id, question: 'why?', output: result.output, calls: result.records } }.writeAnswer sets the answered output for the publish step: for both resolutions above,outputsis'answered=true\n'.writeAnswer never writes results.json, so the publish step posts no review: for both resolutions,filesdoesn't include'results.json'.answerForandpublishAnswertests intest/explain.test.ts.npm run typecheck; if ($LASTEXITCODE) { exit 1 }; if (Select-String -Path src/main.ts -Pattern 'answerFor|trace\.json' -Quiet) { exit 1 }; if (-not (Select-String -Path test/explain.test.ts -Pattern "^test\('writeAnswer" -Quiet)) { exit 1 }; node --test test/explain.test.ts test/pause.test.ts; exit $LASTEXITCODE[ ] 4. Record the changes in the changelog
CHANGELOG.md(edit)## [Unreleased]→### Changed, after the last entry (the one starting- The `api-compat` floor also reads), add these two lines, each as its own bullet:- The tests check that the evals' review cost leaves out the concern judge's and precision judge's calls, which the case cost counts, so \eval:compare` never reports the judges' spend as review cost (#107).`- The tests check what a \/review explain` run leaves for the publish step: only `answer.json` for an unknown finding, without a model call, `trace.json` too when the call ran, the `answered` output, and never `results.json` (#108).`(Write the backslash-escaped backticks as plain backticks.) Change no other section.
if ((Select-String -Path CHANGELOG.md -Pattern 'issues/107\)\)\.' -Quiet) -and (Select-String -Path CHANGELOG.md -Pattern 'issues/108\)\)\.' -Quiet)) { npm test; exit $LASTEXITCODE } else { exit 1 }Review checklist
evals/run.tsmain()writes the same summary fields (precision,findingsPerCase,reviewCostUsd,reviewCostPerCase,costUsd) and per-casereviewCostUsd/costUsdas before.src/main.tsanswer()logs the same lines in the same order, and writes the sameanswer.json/trace.jsonwith the sameansweredoutput;teach()and the review still write throughwriteForPublish.src/main.tsline 10 (./agent.tsimport) is unchanged, so PR Trusted suggestions, safer verification, tracked findings apart #102 merges cleanly.GITHUB_OUTPUTand stay hermetic.polewskm commentedon Oct 31, 2026
Landed in #115. No follow-ups.