Skip to content

clients: assert unknown events stay ignorable, guard action enums, de-time tests - #508

Merged
brentrager merged 1 commit into
mainfrom
fix/client-dispatch-guard-tiers
Aug 19, 2026
Merged

clients: assert unknown events stay ignorable, guard action enums, de-time tests#508
brentrager merged 1 commit into
mainfrom
fix/client-dispatch-guard-tiers

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Test-only follow-up to #505 (shipped in 1.56.0). No changeset — nothing publishable changes, only the tests around it.

#505 landed while I was writing these, so they arrive as a separate PR rather than as commits on that branch.

1. The guard has to encode BOTH tiers of the contract

The stream_reasoning schema says "Clients that do not recognize this event MUST ignore it." That makes two rules, and satisfying one naively breaks the other:

  1. Every event in spec/events/ must decode and be surfaced — what clients: decode interaction + preamble/reasoning frames, add submit_interaction #505's drift guard already asserts, failing the build when a new schema lands unimplemented.
  2. A genuinely unknown event must still be ignored gracefully — a future server sending a frame this client version predates has to be dropped quietly, not throw.

Rule 2 was already correct in all four clients and is untouched here. The problem was that it was untested: nothing stopped someone from "satisfying" rule 1 by turning the catch-all into an error path, which would regress every client the moment the server adds an 18th event. Each client now asserts an unrecognised type is neither surfaced to consumers nor fatal to the turn.

Verified negatively — making Go's dispatch loop failAll on unknown frames instead of continue makes TestUnknownEventIsIgnoredNotFatal fail:

--- FAIL: TestUnknownEventIsIgnoredNotFatal
    stream_token was dropped by the dispatch loop; got []

2. Go was missing the spec/actions/ guard

Python, .NET and TypeScript each got an action-side drift guard in #505; Go did not — and Go's ActionType constants had exactly the same drift that hid submit_interaction in the first place. Added, derived from spec/actions/*.schema.json the same way as the event guard.

3. The dispatch assertions no longer race a clock

This box runs at load ~125 on 12 cores, so timing-based assertions are a real flake risk.

Go now emits every frame plus the terminal, drains the turn's channel to close, and asserts on the collected slice — no timers at all, in either the passing or the failing path. That works because the terminal eventual_response always decodes (it was never part of this bug), so the drain always completes, and a dropped frame shows up as an absent entry in the slice rather than as a hang. The Go interaction tests went from ~4s of sleeping to 0.67s total.

Python's and .NET's remaining waits are hang-detectors rather than assertions, so they move from 2s/5s to 30s. That margin is not theoretical: observed wall time for the Python file alone already ranged 0.9s–3.7s under load, so the old 2s net could have tripped on a slow scheduler rather than on a real bug.

Verified with go test -race -count=10 and 5 consecutive Python runs at load 125, all stable.

Results

language result
Go 65 passed, -race -count=10 stable
Python 72 passed, 1 skipped (gated live e2e); ruff clean
.NET 46 passed, 1 skipped (gated live e2e)
TypeScript 64 passed (10 files); tsc --noEmit clean

All four re-run against current main after rebasing off the squash-merged #505.

…-time tests

Test-only follow-up to #505 (shipped in 1.56.0). No changeset: nothing
publishable changes, only the tests around it.

The drift guard added in #505 has to encode BOTH tiers of the contract, or the
obvious way to satisfy it is to make unknown types an error — which would break
the other half. Per the stream_reasoning schema, a client that does not
recognize an event MUST ignore it, so a frame from a server newer than this
build has to be dropped quietly and leave the turn healthy. That behaviour was
already correct in all four clients and is untouched here; it was simply
untested, so nothing stopped a future "fix" from turning the catch-all into an
error path. Each client now asserts an unrecognised type is neither surfaced to
consumers nor fatal to the turn. Confirmed by making Go's dispatch loop fail the
turn on unknown frames: the new test catches it.

Go had no spec/actions guard, unlike the other three — and its ActionType
constants had the same drift that hid submit_interaction. Added, derived from
spec/actions/*.schema.json the same way.

The dispatch assertions were timing-based, which is a flake risk on a box
running at load 125. Go now emits every frame plus the terminal, drains the
turn's channel to close, and asserts on the collected slice — no timers at all,
in either the passing or the failing path, since the terminal always decodes and
a dropped frame shows up as an absent entry rather than a hang. Python's and
.NET's remaining waits are hang-detectors rather than assertions, so they move
from 2s/5s to 30s: observed wall time for the Python file already ranged 0.9s to
3.7s under load. Verified with go test -race -count=10 and 5 consecutive Python
runs at load 125.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ea67e13

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@brentrager
brentrager merged commit 5e68729 into main Aug 19, 2026
7 checks passed
@brentrager
brentrager deleted the fix/client-dispatch-guard-tiers branch August 19, 2026 21:29
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