Skip to content

Serve a browser test client at /dev - #1

Merged
coderloganli merged 16 commits into
mainfrom
add-web-test-client
Aug 16, 2026
Merged

Serve a browser test client at /dev#1
coderloganli merged 16 commits into
mainfrom
add-web-test-client

Conversation

@coderloganli

Copy link
Copy Markdown
Owner

There was no way to talk to a running deployment by hand. POST /api/session returns a token and POST /api/call/{character_id}/start returns everything needed to join a LiveKit room, but nothing in the repository carried either into a browser, and the eval harness deliberately never opens one.

What this adds

GET /api/personas — a call is started by an id derived from the persona's name (id_for), and nothing published it, so starting a call by hand meant reading an id out of the logs. Read through a new CharacterCatalogReadPort on character-context, implemented by the same ConfigPersonas the call path resolves ids through, so a listed id is an id a call accepts. Unauthenticated, beside /api/session. Three fields on the wire — prompts and the synthesis voice stay off it.

GET /dev — one page, no framework, no build step. Enter a uid, pick a persona, call, talk, hang up. It walks the ordinary contract and gets no privilege. Same-origin with the API, so no CORS layer exists anywhere. Every failure is written into the page as text.

Vendored LiveKit browser SDK, pinned at 2.21.0 (Apache-2.0, licence and provenance in crates/api/assets/dev-client/README.md). The page and the SDK are compiled into the binary with include_str!: compose mounts only models/ and sonari.toml, and a third mount for a test page would make the test tool a deployment concern. Dockerfile and docker-compose.yml are unchanged.

Documents

product.md said a web client was not built. It now says a web product client is not built, and names this one a test tool — Android remains the product surface. architecture.md gains the route and the two unauthenticated endpoints.

Decisions: ADR-0018 (the client lives in the binary), ADR-0019 (vendor the SDK), ADR-0020 (publish the persona list).

Tests

16 new cases, all Rust — the page is for a person, and no browser-side test tooling is introduced. Two of them run against build_router_with_modules rather than a router built in the test, because every other test would pass if the application never merged these routes.

scripts/dev.sh cargo test --workspace --no-fail-fast   184 passed, 0 failed
scripts/dev.sh cargo clippy --workspace --all-targets -- -D warnings   clean
scripts/dev.sh cargo fmt --all -- --check   clean

Note: this branch also carries 4f41b77, renaming docs/prd.md to docs/product.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_016n12Y7LX1cWJHFRV9Jsvss

coderloganli and others added 16 commits August 14, 2026 14:31
Every latency figure sonari had was a single observation, and there was no
accuracy figure at all: the existing harness drove one recording and printed one
line. The optimisation log says as much — "percentiles wait for a golden set".

This adds the golden set and the instrument that reads it.

**The harness.** `sonari-eval generate` synthesises fifteen clips and writes a
manifest; `sonari-eval run` plays them through the system and writes a report of
word error rate, the eight markers of ADR-0010 as percentiles, and how many
samples failed. Two solvers sit behind one trait: one drives the components in
process and runs anywhere with an API key; one drives a running service as a
LiveKit caller and reads the markers back from the call's own events. The report
names which produced it, because only one of them exercises endpointing.

**The markers.** ADR-0010 asked for eight per turn carried as elapsed values.
Forty-odd lifecycle events existed and none carried time. The runtime now emits
one `speech_turn_latency` event per turn with every marker and the two derived
figures, computed where the timings are known rather than by each reader. Two of
them are measured inside the model's streaming loop, which is the only place
they exist. `audio_first_frame` remains unwired; the report says so.

**The clips are synthesised, not recorded,** because the set measures timing and
a person cannot produce a 400 ms pause reliably. The audio is committed rather
than regenerated: the assembly is deterministic but the synthesiser is not, so
regenerating later would silently produce a different set.

The harness records what happened and asserts nothing about what should have.
That principle earned itself during the work: the clip set was built on the
premise that a long pause gets committed early, and the premise looks false —
see docs/tickets/0001. A harness that checked expectations would have reported
failing clips instead of a finding about the system.

Two runs against the real providers corrected the harness itself. Recognition
applies inverse text normalisation, returning `8291` and `7:30` where the
reference says "eight two nine one" and "seven thirty", so the first run reported
13.7% error for recognition that was perfect; the normaliser now folds numbers
and colloquial contractions, and the set scores zero.

CI gains `cargo test -p harness`. Tests needing credentials or a running stack
skip themselves, so CI makes no network calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The harness waited for the bot's audio track before sending the caller's audio.
The runtime does the opposite: it subscribes to the caller first and publishes
its own track only afterwards. Each side waited for the other, which looked like
the bot intermittently missing the caller's track. The gate now waits for the bot
to be present in the room, which is the actual precondition and one a client can
observe.

With that out of the way the real behaviour is visible, and ticket 0002 is
rewritten to describe it: recognition closes about a second into the call, every
frame after that is dropped, no speech is ever detected, and the next call in the
batch gets no runtime at all. The media path was never the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The recognition WebSocket never opens: the log that marks a completed handshake
never appears, and neither does any failure from the socket task. A cancelled
task logs nothing, and the only thing cancelling these is the session-close path
— so the session is being closed about a second in, taking a half-open socket
with it, and the frames that had queued behind it all fail at once.

The network is not involved; ElevenLabs answers in 150 ms from the same compose
network. The open question is now what closes the session, not why recognition
disconnects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The recognition task logged when it started and when it failed, but nothing when
it returned cleanly and nothing while it was connecting. A task cancelled
mid-handshake therefore left no trace at all, which is exactly what has been
happening: the socket never opens, the task is cancelled before any timeout
fires, and the only symptom is every frame afterwards being dropped.

Three lines close that gap — the open attempt, the elapsed time when it
succeeds, and a clean exit — along with a warning when a speech session fails,
which until now was recorded only as a call event.

With them the failure is narrowed to the service image: the same binary, key and
network transcribe correctly from the dev container, and the service container
reaches api.elevenlabs.io over TLS, but inside it the WebSocket handshake never
completes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
Two rustls crypto backends reach this binary — different dependencies pull
different ones — so rustls refused to guess and panicked on the first TLS
handshake. That handshake was the recognition WebSocket's, and the panic happened
inside a spawned task, so it went to stderr and never through tracing. Every
structured log showed the same thing: recognition simply ceased to exist, frames
were dropped, the session failed, and the next call got no runtime.

Installing the provider once at startup is what the panic message asks for, and
is sturdier than hoping feature unification stays lucky across a workspace this
size.

After it, a live call opens recognition in 146 ms, detects speech, flushes an
utterance and returns a transcript. That also retires the reasoning in ticket
0001, which was formed on a system where recognition had never worked: the flush
branch is plainly reachable.

Two behaviours remain open and are recorded in ticket 0002: a session that closes
while its turn is still in flight, and a call that leaves the next one without a
runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
Two failures that end a call were reported at debug: a pipeline task that
returned an error, and one that finished without being asked to. Both are now
warnings — the same lesson as the rustls panic, which was invisible for the same
reason. The inbound audio drain also says why it stopped, since "the caller's
audio stopped arriving" has several causes and they lead different places.

Those lines localise the remaining bug without solving it. With recognition
working, a call now reaches a transcript and is then torn down two seconds in,
while the caller is still speaking: the pipeline's shutdown senders are all
dropped, which is neither the stop path nor the reap path, and neither failure
warning fires. Ticket 0003 records the timings and where to look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The media plane ran as a spawned task whose result nobody read, and every
control-plane error inside it propagated with `?` out of the worker loop. A
single stale fact —

    publish runtime event failed (NotFound): speech session not found

— therefore ended the task, dropping the worker, its map of active runtimes, and
every shutdown sender with them. The call in progress was torn down mid-turn and
no later call was ever claimed, while the process went on answering /healthz as
if nothing had happened.

The plane is now supervised and says what became of it. A rejection about one
session is recorded and skipped rather than ending the loop, and a retryable
failure leaves the action queued instead of propagating. One call's problem stays
one call's problem.

Two consecutive probe calls now complete with a spoken reply, and a fifteen-clip
live evaluation runs every clip.

That is enough for the evaluation set to do its job, and its first live run
confirms ticket 0001: speech is detected, and the utterance is never flushed
until the caller hangs up — twenty seconds after a 4.2 s clip, against a 700 ms
setting. The flush that does appear comes from the close path, not from silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
It was hardcoded to zero, and the comparison behind it is a mean of absolute
sample values, which is never below zero. Every frame therefore counted as
voice, silence was never observed, and a turn ended only when the caller hung
up — twenty seconds after a four-second clip, against a 700 ms setting.

It is now a setting, defaulting to 300: above a −60 dBFS noise floor, which
lands near 33 on this scale, and well below the thousands that speech reaches in
the calls measured so far. It is a starting point for the evaluation set to tune,
not a tuned value.

Four tests pin the behaviour, including the one that would have caught this when
it was written.

Live verification is still pending — the Docker daemon went down before a call
could confirm that a turn now ends on silence rather than on hang-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The threshold change could not be judged: with it set to 300 no speech is
detected on a live call at all. Logging the loudest frame the comparison saw each
second says why — the peak reaching it is 9, on a clip whose frames peak at 16823
and hold near 9000 through the speech. The audio is not quiet, it is absent.

It is absent only sometimes; the same clip has produced a real transcript on an
earlier call. So delivery is intermittent, which is ticket 0004, and it blocks
judging ticket 0001's threshold.

The peak is logged per second rather than a sampled frame: speech is a minority
of frames, and a random sample reports silence during speech often enough to send
an afternoon in the wrong direction — which it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The caller was talking over the hello. The service drops inbound frames while its
own turn is pending, and both eval clients started speaking the moment they saw
the bot's track — so whether a clip was heard depended on whether it happened to
land in the barge-in path. That is what looked like audio vanishing in transport.

The live solver now waits until the bot's audio has been quiet for 700 ms before
speaking, which is what a caller does and keeps this measuring an ordinary turn
rather than a barge-in.

With that, the first complete live evaluation:

    hangover cost    p50 700 ms      exactly silence_flush_ms
    system response  p50 854  p95 976
    perceived        p50 1553 p95 1676

    pause-400    1 utterance   wer 0.00
    pause-600    1 utterance   wer 0.00
    pause-800    1 utterance   wer 0.20   endpoint 185 ms into the gap
    pause-1200   2 utterances  wer 0.22

Turns end on silence after the configured wait rather than at hang-up, and the
boundary sits where the setting says it does: pauses under 700 ms survive, longer
ones lose words or split the utterance. Silence and the cough still open no turn.

That closes ticket 0001, whose answer this set was built to produce, and ticket
0004. The report is kept as the first live baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
It reported `perceived_response_ms: 0.0` on every run, because a subscribed
track delivers frames whether or not anyone is speaking and the probe's ear was
a byte counter. Silence was an answer, and the answer arrived instantly.

Frames now carry their loudness as well as their size. Only audible audio counts
as a reply, and the probe waits before speaking rather than talking over the
greeting — which the service drops on the floor, so its utterance was often never
heard at all.

The measurement is now real: 280 ms to a 1.3 s reply, against a constant zero
before.

The waiting is not yet detection, in either client. Frames keep arriving during
silence, so "wait until they stop" waits the whole window, and the probe finds
the agent's track carries zeros throughout the greeting and real audio only
afterwards. Both are therefore waiting on a clock, which works and is slow.
Recorded in ticket 0004 rather than left implied by a comment that claims more
than the code does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The probe now records the loudest frame on the agent's track each second, which
is what separates "the call is quiet" from "the client is not listening yet".

It shows the greeting arriving as zeros while the service logs it as synthesised
and played, and the reply arriving loud a moment later. Either the greeting is
never written to the room — in which case a caller hears silence until they speak
— or the client's audio stream is not delivering for its first few seconds, in
which case every client here under-reports the start of a call. Ticket 0005 says
how to tell the two apart; guessing between them would have been the third time
this session that a plausible story turned out to be the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
A second run heard it plainly — peaks of 12356 and 10522 in the seconds the
service says it was speaking. In the run where it was missed, the client's audio
stream had produced no frames at all for its first three seconds, and the
greeting came and went inside that window.

So nothing is wrong with the greeting, and the intermittency was never in the
audio: sometimes the stream is delivering by the time the agent speaks, sometimes
it is not. Both eval clients wait before speaking, which stays right, but neither
can rely on hearing the greeting end.

Left in the ticket, unmeasured: the caller sent nothing but noise floor from
second five to twenty-four and the agent never spoke, though
`silence_force_agent_ms` is eight seconds. An evaluation set that only ever
speaks cannot see that timer; it would want a clip of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
A set that only ever speaks cannot see what happens when nobody does.
`idle-force-agent` is twelve seconds of noise floor, and the report counts the
turns the agent started on its own — the greeting is one; a second would mean it
filled the silence.

The first run said the agent greeted and never spoke again, which looked like
`silence_force_agent_ms` failing to fire after eight seconds. Reading the code
before writing that up: the setting is not what its name says. Its deadline is
set at commit time as `silence_force_agent_ms - silence_flush_ms` and is spent
waiting for recognition's final result before going ahead with the partial. A
silent caller gets no turn from it, and the agent was behaving correctly.

So the config comment now describes the code, the report states the count without
implying a verdict, and the clip stays — the behaviour is worth watching whether
or not a setting promises it.

This is the fifth time this session a plausible reading of a symptom was wrong,
and the first time the harness's own rule — record what happened, assert nothing
— was what stopped it becoming a bug report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqX1vTHXSSaHi2ffKG1EoE
The harness reads the product design document by a fixed name, docs/product.md,
at the start of every task. Renaming it removes the per-task lookup rather than
teaching each reader a project-specific name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016n12Y7LX1cWJHFRV9Jsvss
There was no way to talk to a running deployment. POST /api/session returns a
token and POST /api/call/{character_id}/start returns everything needed to join
a LiveKit room, but nothing in the repository carried either into a browser, and
the eval harness deliberately never opens one. Trying a change by hand meant
writing a throwaway page each time.

Three pieces:

- GET /api/personas publishes the persona list. A call is started by an id
  derived from the persona's name, and nothing published it, so a person had to
  read an id out of the logs to call anything. It is read through a new port on
  character-context, implemented by the same ConfigPersonas the call path
  resolves ids through, so a listed id is an id a call accepts. Unauthenticated,
  beside /api/session: the token is free to mint and identifies nobody.

- GET /dev serves a single page that walks the ordinary contract — session, list
  personas, start call, join the room, end call. Same-origin with the API, so no
  CORS layer exists anywhere.

- The page and a vendored copy of the LiveKit browser SDK, pinned at 2.21.0, are
  compiled into the binary. Compose mounts only models/ and sonari.toml; adding
  a third mount for a test page would make the test tool a deployment concern.
  Neither the Dockerfile nor docker-compose.yml changes.

product.md said a web client was not built. It now says a web product client is
not built, and names this one a test tool. Android remains the product surface.

Decisions: ADR-0018, ADR-0019, ADR-0020.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016n12Y7LX1cWJHFRV9Jsvss
@coderloganli
coderloganli merged commit ebb6aef into main Aug 16, 2026
1 of 3 checks passed
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.

1 participant