Skip to content

feat: agent-proposed test cases from a captured trace - #497

Merged
JabbaKadabra merged 19 commits into
masterfrom
feat/test-case-synthesis-agent
Jul 31, 2026
Merged

feat: agent-proposed test cases from a captured trace#497
JabbaKadabra merged 19 commits into
masterfrom
feat/test-case-synthesis-agent

Conversation

@JabbaKadabra

Copy link
Copy Markdown
Collaborator

Summary

Adds Generate tests to the trace detail panel: an agent reads a trace's whole conversation and
proposes the test cases actually worth building — the turns where the agent decided something,
not every turn — as GREEN promotions (lock in what it did) or RED corrections (assert what it
should have done), each with its reasoning. Candidates are reviewed beside the transcript, expected
output is editable, and a plain-language instruction bar refines the round. Turns it passed over
are listed with the reason, so its judgement is auditable rather than opaque.

This replaces the single-click Add test promote dialog, which did a strict subset of the same
job. Traces can still be added to a suite without the agent via Add from traces on the Test
Suites page — that route stays un-gated. Generation itself is Enterprise (TestCaseSynthesis).

What's in it

The unpassable-correction trap. A correction built on the last call of a tool loop can never
pass: that call's input already contains the tool calls and their results, so the only thing
left to grade is the closing summary. Such proposals are flagged, left unchecked, and the agent is
instructed to target the call whose own response holds the decision. Tracey's test-driven
improvement
playbook now routes through propose_test_cases for exactly this reason — it decides
whether the resulting regression test can ever go green — and a prompt-lab scenario probes the live
model to confirm the wording holds.

Evaluator suggestion. When the destination suite's evaluators cannot score what was proposed,
the panel offers an agentic judge: added to that suite (blast radius stated up front, since a
suite's evaluators score every case in it) or carried by a new suite instead. Each of the three
answers names its consequence.

Exact Match now compares tool calls. An expected output that is a tool call carries no text
content, so the previous content-only comparison scored any tool-free response as a pass — a
tool-call expectation asserted nothing. Calls are now compared as an unordered multiset (parallel
calls have no meaningful order), ignoring the provider-generated call id and comparing arguments as
canonical JSON so {"amount": 40} matches {"amount": 40.0}. Existing cases with tool-call
expectations are judged for real from this release, so some will correctly start failing.
A/B
validation is unaffected; historical pass rates are not recomputed, so trend lines may show a step.

Sampling parameters reach the provider. Reasoning effort was mapped onto the outgoing request
and then silently dropped before it left the process — the playground's control did nothing, with
no error. Now sent for real. (n is still dropped; tracked separately.)

~3x faster generation. The panel spent its wait on hidden reasoning it did not need — several
thousand thinking tokens for a few hundred tokens of answer, 25–44s for a four-call conversation.
It now asks the model not to reason (8–13s for the same proposals) and retries without the setting
on models that lack it, so the worst case is the old speed rather than an error. The candidate
column names what it is doing and shows a running clock while a round runs.

Layers touched

  • DomainToolRequestMatch, ExactMatchEvaluator
  • ApplicationTestCase/ synthesis service, budgeted ConversationTranscript,
    ProposalValidator (proposals validated against the real conversation)
  • ApiPOST /api/agent-calls/{id}/test-case-proposals
  • InfrastructureModelClient / ChatClientExtensions sampling parameters
  • LicensingTestCaseSynthesis (Enterprise)
  • Frontendtrace-detail/synthesis/*, SynthesizeTestsModal, Tracey propose_test_cases
    tool + ProposedCasesToolUI, Toast/Radio primitives

Docs

docs/optimization-loop.md, docs/licensing.md, docs/architecture.md, docs/i18n.md,
docs/commands.md, frontend/docs/BEST_PRACTICES.md, frontend/docs/TRACEY.md; manual pages for
test suites, evaluators, traces and Tracey, with two new kiosk screenshots of the panel (including
the unpassable warning). CHANGELOG [Unreleased] covers all of the above.

Also here: i18n:translate now falls back to the repo-root KIOSK_LLM_* config, and the five
catalogs are translated (148 strings; French "outil" hand-corrected to the catalog's existing
"appel de Tool").

Tests

Backend unit + API tests for synthesis, transcript budgeting, proposal validation, tool-request
matching and the sampling parameters; frontend specs for the modal, selection rules and Toast; an
e2e spec generating test cases from a trace and adding them to a suite, plus a licensing-gate spec.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SivSrf9XfAP6Ds1jMCMukB

JabbaKadabra and others added 19 commits July 31, 2026 13:05
An expected output that is a tool call carries zero content parts, so the
content-only comparison scored any tool-free response as a pass. Compare
ToolRequests as an unordered multiset, ignoring the call id (expected ids are
minted locally by TestSuiteDtoMapper, actual ids come from the provider) and
comparing arguments as canonical JSON.

Existing cases with tool-call expectations are judged for real from here, so
some will correctly start failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
…tion

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
…ersation

Adds the ITestCaseSynthesisService seam, the proposal contracts, the
test_case_synthesizer prompt, and the system-agent call. Refinement is a real
Conversation rather than a re-prompt, so a follow-up instruction makes the agent
revise its previous answer instead of starting over.

Nothing is persisted: proposals are a suggestion surface, not an entity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
Drops proposals naming a call outside the conversation, a call with no response,
or a correction with no expected answer; flags an unpassable correction and an
unknown tool; dedupes by (call, kind) and caps by relevance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
Project-scoped like its siblings (404, never 403, so an id is not an existence
oracle) and gated by RequiresFeature(TestCaseSynthesis). Read-only: writes stay
on the existing test-suite endpoints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
The proposal enums are named TestCaseProposal* because `ProposalKind` is
already taken in models.ts by the OPTIMIZATION proposal kind. C# keeps the two
apart by namespace; this module is flat, so the distinction lives in the name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
The trace detail gains a "Generate tests" action opening a review panel: the
conversation on the left, ranked candidates on the right, a free-text
instruction at the bottom. Generation never runs on open — opening a modal must
not spend tokens on the project's system endpoint.

Extracts SuitePicker out of PromoteModal (keeping its existing testids) so both
modals share one destination picker.

i18n: the 25 new strings are extracted into the English source catalog but
untranslated in de/es/fr/it — i18n:translate needs I18N_TRANSLATE_API_KEY,
which is not set in this environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
…e cases

The agent's scoring suggestion is approved separately from the cases, with the
blast radius stated: attaching a judge scores every case already in the suite,
because a case passes only when EVERY attached evaluator passes. The new-suite
alternative sidesteps that, and is hidden when MaxTestSuites leaves no room.

Splits the write side into useApproveProposals and the candidate column into
ProposalsPane to stay inside the 120-line hook and 150-line component limits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
A read tool over the same endpoint the Generate-tests panel uses, so the chat
path gets the same turn selection. Its digest carries agentCallId + kind per
candidate because those are exactly what add_to_suite / create_suite need next
— the model must be able to act without a second read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
Covers the AI docs (optimization-loop Stage 1, domain-concepts, architecture,
TRACEY.md), the user manual (test-suites-and-cases, capturing-traces, tracey,
evaluators), and the changelog.

The manual already explained the unpassable-correction trap for hand promotion;
the new section notes that the generate flow avoids it for you. The evaluators
page carries an upgrade warning, since existing tool-call expectations were
asserting nothing and some cases will correctly start failing.

Screenshots still owed — they need the kiosk stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
The panel holds the hook's `abort` as `useEffect(() => abort, [abort])` so that
closing it tears down an in-flight generation instead of billing the user for a
request they walked away from. But `abort` was rebuilt on every render, so the
effect saw a changed dependency on EVERY re-render and ran its cleanup — killing
the request that was still running. Any state change during generation triggered
it; in the browser the conversation query settling was enough, so the panel never
produced a single proposal. `useCallback` gives the identity the effect assumes.

The existing specs all resolve the mocked endpoint synchronously, which closes
the window entirely — hence a real-browser bug under a green unit suite. The new
spec defers the response and switches the destination suite mid-flight, which is
the same re-render with no timing to arrange.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
Drives the whole Generate-tests flow against the live stack: a seeded three-turn
refund conversation, open the deciding trace by ?trace=, generate, approve, and
read the destination suite back over the API. This is what caught the abort bug
fixed in the previous commit — the panel could never produce a proposal in a real
browser while every unit spec stayed green.

Runs in its own `llm-generate-tests` project since synthesis is a real model
round-trip. The model chooses how many candidates to return (one and two have
both been observed for the same conversation), so the spec approves up to two and
asserts the suite grew by exactly what was ticked, rather than betting on a count
the model never promised.

Also gates the new feature on the Free tier: POST test-case-proposals must 402.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
test-driven-improvement's job is to turn a reported defect into a regression test
that can actually go green, and the step that decides whether it can is choosing
WHICH call to correct. A turn that used tools is several calls; correcting the
last one produces a case that is unpassable by construction, and reads exactly
like a fix that did not work. The playbook asked the model to pick by eye.

It now asks propose_test_cases, passing the user's complaint as `instruction`:
the synthesis agent targets the deciding call and flags an unpassable correction
before anything is written. A flagged candidate is a stop, not a note. When it
also suggests a judge, step 6 starts from that rather than inventing one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
The point of routing test-driven-improvement through propose_test_cases is that
it must correct the call that DECIDED, not the summary that reports it — and that
is a model judgement, so only a live run can tell you whether the wording works.
This scenario puts the trap in front of it: a two-call refund loop where the
flagged `Unpassable` candidate is ranked first.

Verified against the kiosk endpoint. The baseline (HEAD~1) writes the case
against the summary call, exactly the failure the playbook exists to prevent; the
working copy calls propose_test_cases, says out loud that the final trace is
flagged, and writes against the deciding call.

The run also caught the playbook offering to "move to step 8" — its own step
numbers are not the user's vocabulary. Guardrail added and re-verified.

Scenario-local fixtures because the shared ones contradict what the scenario
writes (add_to_suite echoes a hardcoded agentCallId, set_suite_evaluators reports
a set it was not sent, get_case_results answers about a different run), which
reads as a prompt failure when it is a harness one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
Two shots from the kiosk stack: the panel with two approved candidates and the
editable expected tool call, and a candidate carrying the unpassable warning. The
second earns its place — "a correction on a summary turn can never pass" is the
hardest thing on this page to picture from prose, and the warning states it in
the words the product uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
i18n:translate wanted its own I18N_TRANSLATE_API_KEY, so a machine with a working
kiosk endpoint still could not translate — and the catalogs shipped untranslated
because nobody had the second credential to hand. The KIOSK_LLM_* trio in the
repo-root .env is already this machine's general-purpose local model config: the
kiosk compose file and the prompt-lab skill both read it. Translation now does
too.

Precedence is process env, then that .env; I18N_TRANSLATE_* still wins and takes
the WHOLE endpoint when set — a base URL borrowed from the kiosk while the key
came from elsewhere would send that key somewhere it does not belong. The run
prints the model and endpoint it resolved, so which one answered is never a guess.

The .env read is a six-line parser rather than a dependency: npm runs this script
directly, so nothing loads the file for it, and Compose's KEY=value format needs
no more than that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
148 strings (37 x de/es/fr/it), machine-translated through the kiosk endpoint.

Reviewed the diff rather than trusting it: ICU placeholders and Lingui tags are
intact across all 7,916 translated entries, and the do-not-translate glossary
holds except for two French strings where "tool" came back as "outil" — corrected
by hand to match the catalog's existing "appel de Tool".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011i3fSHNnyWiHqi5dYaFxf9
@JabbaKadabra
JabbaKadabra merged commit 7b930d6 into master Jul 31, 2026
12 checks passed
@JabbaKadabra
JabbaKadabra deleted the feat/test-case-synthesis-agent branch July 31, 2026 20:11
const glossary = loadGlossary()
const { apiKey, baseURL, modelId } = makeModel()
const { apiKey, baseURL, modelId, source } = makeModel()
console.log(`Translating with ${modelId} via ${baseURL || 'api.openai.com'} (${source}).`)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants