feat(sim): judge findings, run metrics, and the chat fixes they exposed - #61
Merged
Merged
Conversation
…son from The judge was asked one question — did the person get what they came for — so that is all it ever answered. Everything else a run exposes went unreported unless the dispatching agent happened to brief it, and those per-run briefings steered verdicts toward what the agent already suspected and made two runs incomparable. The verdict now carries an open `findings[]` alongside the person-facing `frictions[]`: cost, what the conversation was like to read, tools, the product, the harness, and anything the run exposed that those do not name. `satisfied` still turns on the goal alone. The judge reads `docs/architecture/` itself, so its context comes from the repository rather than from whoever dispatched it, and the skill now says to dispatch every case identically. `lib-sim/metrics.ts` derives the counts that are tedious and error-prone to read off a transcript, and `writeEvidence` folds them into the sidecar. They are evidence, never a score: no threshold, no enum, nothing that decides a verdict — a metric that decided anything would narrow the judge to what happens to be countable. Two of them exist because the record lies by default: `toolCalls` is cumulative, and a sub-agent's requests interleave with the lead's. Validated against the four recorded data-fair runs. The gateway capture now also counts the characters the host injects as `<host-state>` / `<host-events>` / `<hidden-context>` blocks — they live in tool results and hidden context, so that request is the only place the cost of the host-events mechanism can be measured. Root-causing stays with the coding agent: the judge has the record, not the source, and a file name it never read sends someone down the wrong path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…ment Every model role was pinned to SIM_ASSISTANT_MODEL, so the sub-agents the chat-subagent case exists to exercise ran on sonnet. That both costs more per case and flatters the product: a sub-agent prompt only a large model can follow reads as working right up until a deployment runs it on the small tier. SIM_TOOLS_MODEL (default haiku) now covers tools, summarizer and moderator, and the sidecar records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
The persona's reply was assembled by appending the text of EVERY assistant message in its agent loop. With perception wired in the SDK emits one message per reasoning step between tool calls, so the persona's inner monologue was sent to the assistant as the person's own words — in one run naming the tool the assistant should call, contaminating exactly what that case measures. In another it welded DONE onto the end of a sentence, so isDone() missed it and the run ran on for an extra turn and a 124s wait, ending with the simulated person believing the product was broken. Only the last message that carried text is the reply. `click` reported `clicked "X"` after the getByText fallback landed on a paragraph, indistinguishable from activating a control — Playwright clicks whatever is visible. It now says when all it found was text. The click is still allowed: a person can click text, and text is often inside a clickable div. `toolResults` are now captured. They were in the request body as role:"tool" messages and were being discarded, which left a judge able to see what the assistant asked a tool but never what came back — "the form data is valid and saved" was unfalsifiable for exactly that reason. Capped per result so one payload cannot swallow the evidence file. `emptyAssistantBubbles` is renamed `textlessAssistantBubbles` and documented: three judges in a row read it as an assistant gone dark, when every instance was a tool-call bubble that does render a chip on screen. The report also prints the tools tier beside the assistant's, which it was collecting and not showing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
The wait_for_user_action history chip used the same present-tense label as the live activity line, so once the turn ended it sat in the transcript forever saying "Waiting for: User clicks Create". Every other chip is a noun, so this was the only one worded as current page state. A judged run watched the cost: the assistant answered correctly that the list had been created, the person read the stale chip as live, concluded the assistant was lying, and spent four turns hunting a button that no longer existed — ending with the assistant capitulating and offering to start the wizard over. The list had existed correctly the whole time. The chip already knows when it settled (it colours itself green off invocation.state), so it now reads "Waited for: …" once done. Also records this run's verdict, which is worth keeping as a fixture: a case where the product behaved and the person still left believing it was broken. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
A suite takes minutes, so it gets backgrounded, and a run that never launched looks exactly like a run still going. Two additions to the skill: Check that a sidecar appears within a minute — writeEvidence writes it before each case begins, so an empty simulations/tmp/ is proof the run is not going. Wait on the pid, not on a text pattern. `pgrep -f` matches full command lines including the waiter's own, so `until ! pgrep -f "playwright.sim.config"` waits on itself forever, silently. One session lost an hour and three quarters to exactly that and then repeated it while trying to fix it, including a `pkill -f` that killed its own shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
computeMetrics split conversations by message-count interleaving alone and called every non-lead stream a sub-agent. In a judged data-fair run that reported three `summarizer` compaction calls as sub-agent dispatches carrying 31kB task prompts — in a run that made no sub-agent call at all. The judge caught it from the transcript: no `subagent_*` entry anywhere in toolCalls. The gateway has recorded `model` all along. It is now the authority: requestsByModel reports the split by role, leadRequests/nonLeadRequests replace mainRequests/subAgentRequests, and the largest non-lead prompt names the role that took it — a summarizer legitimately carries the whole conversation, so the role has to be read before the size means anything. The interleaving split stays as the fallback for records that name no role. The judge definition now says it too, with the failure that prompted it: never call a request a sub-agent dispatch without a subagent_* call to point at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
**A wait blocks at most once per turn.** The person cannot act while the turn is still open, so a second block can only run out the clock, and the repeated-call loop guard never caught it because the model rewords `expecting` each time and the guard keys on arguments. Two runs paid for it: one where a keyed state re-emission caused by the assistant's OWN tool call settled the wait instantly and it re-issued the identical call, one where three reworded waits cost six minutes and produced two "take your time" bubbles. A host that wires no turnId keeps the old behaviour. **The host blocks stop claiming what they cannot deliver.** They said the model was kept up to date and must never ask what is on screen. Coverage is partial by construction — a page publishes what it chose to, and nothing reports dialogs or overlays — so on a screen the events did not describe, the only ways out were to break the instruction or to invent. A run did both, guessing "scroll to the bottom" and "press F5" at a person who could see neither. Inventing is the harm; the blocks now name their limits and say to admit what cannot be seen. **Tool chips keep their labels after the page unmounts.** Titles were read from the live registry, so a chip already in the scrollback turned back into its raw snake_case name, rewriting what the person read minutes ago — five wizard chips did exactly that to a persona whose defining trait is that interfaces tire them. **The persona's look keeps the end of the page.** Overlays are teleported to the end of the DOM, so a head-only cut removed precisely what a person was being asked to look at: one run clicked "Ajouter une colonne", the snapshot was cut at the same point before and after, and whether the dialog opened was not decidable from the record at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…trates the mechanism
**SIM_USER_MODEL defaults to sonnet.** On haiku the persona stopped enforcing
its own goal: a case whose goal says the answer must be shown on screen was
ended with a chat-only reply and marked done, and another asserted it could see
nothing but the chat while its own look had just returned the page. A persona
that lets the product off the hook produces green runs that prove nothing. Run
side by side, the sonnet persona noticed the missing display ("that's actually
on screen and clear"), pushed back on thin data and demanded verification.
The same experiment cleared a question the other way: register-person's
front-loading is NOT a model limit — the sonnet persona front-loaded too. The
goal text names all three facts in one line, so one-shot is the obvious move.
That case's premise has to move into the goal, not the persona.
**The chat-subagent fixture stops teaching the agent to guess.** Its mock rows
were dated 2024-01-15 against a 2026 clock, so a persona asking for air quality
"right now" was correct to refuse them and six of seven turns went to arguing
about freshness; timestamps are now relative to now. And the page published
nothing at all (hostBlockChars 0) while its tools demanded a dataset name
nothing could supply — the worker invented four names, then refused to guess and
asked the caller for one. The page now publishes the dataset it is showing as
keyed state, so retention hands it to the lead on activation and the lead
relays it into the task. Host state reaches the lead only, never sub-agents, so
that relay is the only path there is.
Measured on the same case: 270s/7 turns -> 79s/3 turns, four invented dataset
names -> zero, four worker refusals -> zero, set_display called, and
hostBlockChars 0 -> 913.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…y hand Nothing in the library tracks navigation on its own — a host publishes what it knows and retention puts it in the activation snapshot — so every host with a router was writing the same publisher. data-fair already had one; the dev pages had another; the chat-subagent page had none at all, which is how its worker ended up inventing dataset names. What it adds over calling useAgentState directly is the absolute url, derived from the path against the current origin unless the host supplies its own. A judged run had the assistant hand the person a relative path that the chat rendered as inert plain text — not even a broken link — while the absolute URL sat unused in the host-state block of the very same request. A host can no longer publish a location the model can only turn into a dead end. buildAgentLocation is exported separately and pure, so a host can unit-test what it publishes without a browser. The workflow dev page now uses the helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
An empty simulations/tmp/ after a minute is not proof a run stalled: the sidecar lands after the login fixture, which can take longer than that on a cold page. Check the runner log first and the sidecar as the stronger signal. And check which directory you are reading. A backgrounded `cd X && … &` runs the cd in a subshell, so a later `ls` reads whatever directory you started from — one session read another repo's stale evidence that way and called a dead run healthy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
**The query_data mock contradicted itself.** Every argument set returned the
same three raw rows EXCEPT a hard-coded avg-by-station branch reporting four
stations, so the fixture said both "two stations" and "four stations". A judged
run had the worker spend six calls reaching a confident "definitively only 2
stations" the page's own data contradicts, and the person walked away with a
banner the fixture does not support. The case could not tell a worker that
verified something from one that guessed — the only thing it exists to test.
There is now one table, and filter/groupBy/aggregation are actually applied to
it.
**chat-vjsf's set_data destructured `{ args }`** while useAgentTool forwards
straight to navigator.modelContext.registerTool, which calls execute with the
arguments object itself — every other tool in the repo takes `(args)` and works.
It would have written undefined into the form; the model kept preferring vjsf's
own setData, so it never fired. A judge flagged the disagreement and said
honestly it could not tell which side was wrong.
**isDone read the whole message.** The persona is asked for DONE and nothing
else, and a capable one signs off first ("That matches what I'm seeing — good.
\n\nDONE"), so the runner sent the sign-off to the assistant and paid a model
request for a pleasantry nobody reads, once per case per suite. It now reads the
last line, which still refuses "let me know when it is DONE".
Also removes a paragraph this branch had duplicated in host-events.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
The sidecar records which model ran, and its whole purpose is that verdicts from different tiers are never compared silently. Hosts re-derived the default with their own literal, and a comment in data-fair said in so many words that this would go stale if the package ever changed its default. It did, and it went stale in the first run that exercised it: a data-fair simulation ran the persona on sonnet and recorded haiku. A cross-repo run caught what neither repo's own suite could. DEFAULT_USER_MODEL and resolveUserModel are now exported, nextUserMessage uses resolveUserModel itself, and the runner records what it returns rather than a copy of the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
…cked The cap I added to stop wait->wait loops made a known annoyance into a hard block. waitForEvent answers instantly from the pending buffer, and what sits there is routinely a keyed state re-emission the assistant's OWN tool call produced. That non-wait consumed the turn's one allowed block, so the real wait was then refused. A judged data-fair run showed the cost: the decisive wait for the person's Create click was eaten by a `wizard ready:true` transition, the follow-up wait came back "You already waited in this reply", and the person was told three times to press a button the assistant had no way to observe, across two 120s timeouts — two thirds of a 328s run. The store now reports whether a wait actually blocked, and only a blocking wait spends the allowance. The loop protection is unchanged: a wait that blocked, or timed out, still consumes it. I had found this pending-buffer behaviour earlier and decided it was indistinguishable from the user clicking early, which is true in isolation and was the wrong call once the cap sat on top of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
I had been gating on test-unit alone and calling the branch verified. The e2e
suite found both of these.
Publishing host state from the sub-agent dev page put a <host-state> block ahead
of the visible message on its activation turn, and the mock's `call tool`,
`call tools` and `parallel subagents` directives are anchored on the whole
message — so the chaining test got "what do you mean ?" instead of a delegation.
The mock's own comment records that `hello` had already been moved to
endsWithCommand for exactly this reason; the tool directives had not. They now
read the trailing command line, which is unit-tested.
Two host-events assertions pinned `location: {"path":"/workflow"}`.
useAgentLocation adds the absolute url, which is the whole reason it exists over
a bare useAgentState, so the assertions now check both halves rather than being
loosened.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGtNVPW8sF6PP2BHUvTX1u
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The simulation harness judged runs, but its verdicts were hard to act on: the judge saw what the assistant asked a tool and never what came back, and had no sense of what a run cost. This widens its mandate, gives it evidence, and then fixes what the resulting runs found.
Harness
findings(cost, conversation, tools, product, harness) alongside user-facingfrictions, readsdocs/architecture/itself, and is dispatched identically every run so two verdicts stay comparable.lib-sim/metrics.tsderives counted facts from each transcript — requests per user message, the split by model role, the largest non-lead prompt, duplicate tool calls, host-block volume — into the run sidecar andsimulate:report. Evidence, never a score.toolResultsandhostBlockChars, so a claim like "the form data is valid and saved" becomes checkable.sonnet(onhaikuit stopped enforcing its own goal), sends only its final utterance instead of leaking its reasoning, accepts a sign-off beforeDONE, sees the end of the page as well as the top (dialogs are teleported there), and is told when a "click" only hit text.SIM_TOOLS_MODEL), as a deployment would.Chat and product fixes the judged runs exposed
wait_for_user_actionblocks at most once per turn — and a wait answered from the pending buffer doesn't spend the allowance, so the assistant can still wait for the click it just asked for.snake_casewhen the page that registered the tool unmounts.useAgentLocationpublishes the location with a derived absoluteurl; a run had the assistant hand over a relative path that the chat rendered as inert plain text._devsub-agent fixture answers from one coherent, relatively-dated table and publishes its dataset, instead of contradicting itself.Why: make the simulation harness produce verdicts worth acting on — and act on the ones it produced.
Heads-up:
useAgentLocationis a new public export inlib-vue, consumed outside this repo. The per-turn wait cap is the change to review hardest: it keys oncurrentTurnIdfromuse-agent-chat.tsand signals whether a wait really blocked through a mutablelastWaitBlockedfield on the store rather than a return value.