Skip to content

Add cancel + Rich Interaction conformance scenarios (th-eae69d) - #513

Merged
brentrager merged 5 commits into
mainfrom
test/conformance-cancel-interaction-scenarios
Aug 19, 2026
Merged

Add cancel + Rich Interaction conformance scenarios (th-eae69d)#513
brentrager merged 5 commits into
mainfrom
test/conformance-cancel-interaction-scenarios

Conversation

@brentrager

@brentrager brentrager commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The gap

spec/conformance/scenarios/ is the corpus all five servers replay. It had no cancel scenario, no interaction scenario, no choices scenario.

So cancellation and Rich Interactions — the two newest features — were cross-checked only by fixture shape (does an interaction_required event match its schema), never by cross-language behavior. A port could be fully green on parity while implementing neither correctly. That is exactly what happened: six independent reviewers found the same divergences in four different languages because nothing in CI was positioned to catch them.

What this adds

Eight scenarios, plus an expiring xfail marker the five runners honour. Test-only — no server changes, no changeset.

scenario what it pins
cancel-mid-turn terminal cancelled (499, echoing the turn's requestId) in place of eventual_response; turn slot freed
cancel-no-active-turn-noop a cancel with no active turn emits nothing
interaction-park-resume identity_intake parks behind identity_form; matching submit_interaction resumes
interaction-invalid-retryable invalid values then interaction_invalid, turn stays parked (twice), then a corrected submit resumes
interaction-stale-id-rejected stale interactionId then error/INTERACTION_MISMATCH, turn stays parked
interaction-declined declined: true resolves the park without values
interaction-conversational-fallback no declared capability, so text fallback, never a park
interaction-choices-park-resume the choices kind rides the same generic envelope

Two design notes:

  • Making cancel deterministic. A mock turn finishes faster than a cancel frame can race it, and the format has no slow-tool directive (server.tools entries return a fixed string immediately). cancel-mid-turn opens its in-flight window with a write-confirmation park — the one pause this corpus can express.
  • Asserting "nothing arrives". No runner has a drain check, so cancel-no-active-turn-noop does it structurally: the cancel step expects zero events, so any stray event is consumed by the next step's first matcher and fails it.

knownDivergences — an expiring marker, not a skip

There was no skip/allowlist/xfail mechanism in any runner, so this PR adds one. A scenario names the languages it fails on today, with the reason and pearl id right beside it:

"knownDivergences": ["go", "typescript", "python", "dotnet"],
"knownDivergencesReason": "th-eae69d — these four emit the raise tool's toolCall chunk BEFORE interaction_required ...",

Both halves of the contract are implemented, and the second is the point:

  • a listed language that fails is reported (reason + the real assertion) and does not fail the build;
  • a listed language that passes fails the build: remove <lang> from knownDivergences in <scenario> — it now passes.

Without the xpass half the markers rot silently and we recreate the exact "green tests that prove nothing" problem this corpus exists to catch. A marker is a tracked bug with an expiry, never an accepted difference.

Marked strictly to the measured matrix — the five interaction park scenarios for go/typescript/python/dotnet, and cancel-mid-turn for go/dotnet. interaction-conversational-fallback already passes everywhere and is deliberately unmarked.

*testing.T and panics do not catch alike, so the runners differ: Rust uses tokio::spawn so the panic surfaces as a JoinError; Go narrows *testing.T to a small parityT interface and runs a marked scenario against a recorder whose Fatalf panics with the message (testing.T's own Fatalf is terminal and cannot be un-failed); TypeScript, Python and .NET catch the assertion.

The two divergences, exactly

1. Park event vs. the raise tool's stream_chunk — Rust is 1 of 5, and Rust is right

Failing assertion, identical in four languages: expected interaction_required, got stream_chunk.

Ruled a port bug, not a protocol variant, on three grounds:

  1. All five already defer the gated tool's chunk until after the prompt for the other park type (hitl-write-confirmation) — so the four are internally inconsistent between their two park paths, while Rust is consistent.
  2. Rust is the designated reference; the ports mirror it.
  3. Semantically a client that renders tool calls would otherwise show "calling request_identity_intake..." before the card appears — framework internals leaking ahead of the semantic event.

The four ports change, not these scenarios.

2. Go and .NET keep running a cancelled turn

Failing assertion in cancel-mid-turn: stream_token: accumulated "" != "The turn slot was free, so this second turn ran."

The turn after the cancel produces no reply because the cancelled turn consumed an extra LLM response. In Go the write-confirmation gate returns a deny rather than unwinding (return core.Deny("connection closed before confirmation"), gateCtx.Err()), the agent loop makes one more model call, and the output is merely suppressed (if turnCtx.Err() != nil { return }). .NET has the same signature.

Verified, not inferred: re-running with one extra mockLlmScript entry makes both pass, proving the entry is eaten by the cancelled turn.

Cancellation is a mute button there, not a stop button — after a visitor hits Stop, the turn keeps burning model calls and keeps executing whatever the agent loop does next, invisibly. Rust, TypeScript and Python abort properly. Corroborates th-8628bf independently; filed against the ports.

Verification

Against origin/main (f91008e), both directions, per language.

Real corpus — all five green:

suite result
Rust cargo test --test scenario_parity 18/18
Go go test -count=1 -run TestScenarioParity ok, 6 divergences logged
TypeScript vitest run test/scenario-parity.test.ts 18 passed, 5 divergences logged
Python uv run pytest tests/test_scenario_parity.py 13 passed, 5 xfailed
.NET dotnet test --filter ~ScenarioParity 19 passed

xpass guard — with a passing scenario temporarily marked for all five, every runner fails with its "it now passes" message. All ten pre-existing scenarios still pass on all five.

gofmt, cargo fmt, cargo clippy, tsc --noEmit and ruff are clean.

Iterating locally: go test caches results and does not invalidate on a scenario-JSON edit — use -count=1.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9d87c3c

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 added a commit that referenced this pull request Aug 19, 2026
Per the ruling on #513: Rust's park ordering is correct (the four ports
change, not the spec), and this must not merge red. So the new scenarios
now carry an EXPIRING xfail marker that all five runners honour.

A scenario names the languages it fails on today, with the reason and the
pearl id right beside it:

  "knownDivergences": ["go", "typescript", "python", "dotnet"],
  "knownDivergencesReason": "th-eae69d — these four emit the raise tool's
   toolCall chunk BEFORE interaction_required ..."

Both halves of the contract are implemented, and the second is the point:

  * a listed language that FAILS is reported (reason + the real assertion)
    and does not fail the build;
  * a listed language that PASSES FAILS the build — "remove <lang> from
    knownDivergences in <scenario> — it now passes".

Without the xpass half the markers rot silently and we recreate the exact
"green tests that prove nothing" problem this corpus exists to catch. A
marker is a tracked bug with an expiry, never an accepted difference.

Marked strictly to the measured matrix: the five interaction park scenarios
for go/typescript/python/dotnet, and cancel-mid-turn for go/dotnet. The
sixth interaction scenario (interaction-conversational-fallback) already
passes everywhere and is deliberately unmarked.

*testing.T and panics do not catch alike, so the five runners differ:

  rust    tokio::spawn, so the scenario's panic surfaces as a JoinError
  go      narrow *testing.T to a small parityT interface, then run a marked
          scenario against a recorder whose Fatalf panics with the message
          (testing.T's own Fatalf is terminal and cannot be un-failed)
  ts/py   catch the assertion (vitest / pytest.xfail)
  dotnet  catch XunitException

Verified in both directions, per language: with the real corpus all five are
green (Rust 18/18, Go/TS/.NET green with divergences logged, Python 13 passed
+ 5 xfailed), and with a passing scenario temporarily marked for all five,
every runner fails with its "it now passes" message. gofmt, cargo fmt,
clippy, tsc --noEmit and ruff are clean.

Note for anyone iterating locally: `go test` caches results and does NOT
invalidate on a scenario-JSON edit — use -count=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
brentrager and others added 5 commits August 19, 2026 19:38
The shared conformance corpus every server replays had no cancel scenario
and no interaction scenario. Cancellation and Rich Interactions — the two
newest features — were cross-checked only by fixture SHAPE, never by
cross-language BEHAVIOR, so a port could be fully green on parity while
implementing neither correctly. That is why six reviewers independently
found the same divergences in four languages: nothing in CI was positioned
to catch them.

Eight scenarios, test-only, no runner or server changes:

  cancel-mid-turn                     terminal cancelled/499 echoing the
                                      TURN's requestId, then the slot is free
  cancel-no-active-turn-noop          a stray cancel emits nothing
  interaction-park-resume             identity_intake parks + resumes
  interaction-invalid-retryable       invalid values stay PARKED, twice
  interaction-stale-id-rejected       INTERACTION_MISMATCH, stays parked
  interaction-declined                declined:true resolves without values
  interaction-conversational-fallback no capability -> no park
  interaction-choices-park-resume     the choices kind, same envelope

A mock turn finishes faster than a cancel can race it and the format has no
slow-tool directive, so cancel-mid-turn opens its in-flight window with a
write-confirmation park — the one pause the corpus can express.

These go in RED on four servers. That is the deliverable: the audit's
systemic findings become CI-visible facts. Two real divergences, detailed
in the README:

  * Rust emits interaction_required BEFORE the raise tool's stream_chunk;
    Go/TS/Python/.NET emit the chunk first. The scenarios assert Rust's
    order — it is the reference, and it is the order the corpus already
    pins for the other park type (hitl-write-confirmation), which the
    other four also honor, making them internally inconsistent between
    their two park paths. Whether the spec adopts chunk-first instead is
    a protocol decision, not a test fix.
  * Go and .NET keep running a cancelled turn: it consumes one more LLM
    response after the cancel and is merely gagged, not aborted.
    Cancellation is a mute button there, not a stop button. Confirmed by
    re-running with an extra mockLlmScript entry — both then pass, so the
    entry is provably eaten by the cancelled turn.

Verified on all five: Rust 18/18. TS, Python 13/18 (the 5 park scenarios).
Go, .NET 12/18 (those 5 plus cancel-mid-turn).

There is no per-language skip/allowlist/xfail mechanism in any of the five
runners and none can be added in JSON, so these land on all five at once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per the ruling on #513: Rust's park ordering is correct (the four ports
change, not the spec), and this must not merge red. So the new scenarios
now carry an EXPIRING xfail marker that all five runners honour.

A scenario names the languages it fails on today, with the reason and the
pearl id right beside it:

  "knownDivergences": ["go", "typescript", "python", "dotnet"],
  "knownDivergencesReason": "th-eae69d — these four emit the raise tool's
   toolCall chunk BEFORE interaction_required ..."

Both halves of the contract are implemented, and the second is the point:

  * a listed language that FAILS is reported (reason + the real assertion)
    and does not fail the build;
  * a listed language that PASSES FAILS the build — "remove <lang> from
    knownDivergences in <scenario> — it now passes".

Without the xpass half the markers rot silently and we recreate the exact
"green tests that prove nothing" problem this corpus exists to catch. A
marker is a tracked bug with an expiry, never an accepted difference.

Marked strictly to the measured matrix: the five interaction park scenarios
for go/typescript/python/dotnet, and cancel-mid-turn for go/dotnet. The
sixth interaction scenario (interaction-conversational-fallback) already
passes everywhere and is deliberately unmarked.

*testing.T and panics do not catch alike, so the five runners differ:

  rust    tokio::spawn, so the scenario's panic surfaces as a JoinError
  go      narrow *testing.T to a small parityT interface, then run a marked
          scenario against a recorder whose Fatalf panics with the message
          (testing.T's own Fatalf is terminal and cannot be un-failed)
  ts/py   catch the assertion (vitest / pytest.xfail)
  dotnet  catch XunitException

Verified in both directions, per language: with the real corpus all five are
green (Rust 18/18, Go/TS/.NET green with divergences logged, Python 13 passed
+ 5 xfailed), and with a passing scenario temporarily marked for all five,
every runner fails with its "it now passes" message. gofmt, cargo fmt,
clippy, tsc --noEmit and ruff are clean.

Note for anyone iterating locally: `go test` caches results and does NOT
invalidate on a scenario-JSON edit — use -count=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI runs `go test -race`, and cancel-mid-turn trips it: a DATA RACE in
core's MockLlmProvider.ChatStream, where the CANCELLED turn's goroutine and
the NEXT turn's goroutine pop the same unguarded FIFO script concurrently.

That is independent, mechanical proof of the divergence the scenario's
assertion already catches — the cancelled turn is genuinely still executing,
not merely mis-reporting. Reproduces deterministically, locally and in CI.

A knownDivergences marker cannot and must not suppress it: the race detector
fails the test outside the runner's assertion path. So vet-test (go/server)
stays red until Go actually aborts a cancelled turn. Documented rather than
worked around — guarding the mock's FIFO would silence the evidence and
leave the bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The go marker on cancel-mid-turn bought nothing and would have backfired.
It covers only the assertion; the data race (th-f2ac48) fails the test
outside the runner's assertion path, so vet-test (go/server) was red with
the marker anyway. And once fix-cancel-unwind lands, Go would start PASSING
the assertion — firing the xpass guard and re-reding the build with 'remove
go from knownDivergences' instead of going green.

Unmarked, cancel-mid-turn turns green on its own the moment Go actually
unwinds a cancelled turn. That is the proof the port fix worked.

.NET stays marked: it has no race, so the marker is what keeps build-test
green today, and its xpass guard is what will tell us when it is fixed.

Also spells out in the README that hitl-write-confirmation — the scenario
proving the four ports are internally inconsistent with their own other
park path — is one ALL FIVE pass today, so the ordering ruling does not get
re-litigated by the next reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#514 made Go and .NET actually unwind a cancelled turn, and the corpus
proved it twice, unaided:

  * Go was UNMARKED and went green on its own, scenario untouched, race
    detector included. The corpus said Go was broken, the port was fixed,
    and the corpus now agrees without anyone editing it.
  * .NET was marked, so the xpass guard FIRED on its first real
    opportunity: 'remove dotnet from knownDivergences in cancel-mid-turn
    — it now passes'. Marker removed; .NET is back to 19/19.

That is the whole point of an expiring marker over a skip: it told us the
moment it became a lie, instead of rotting into a green check that proved
nothing.

The five interaction-ordering markers (th-ef78d0) are untouched — those
still genuinely fail and the ruling stands: Rust's order is correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@brentrager
brentrager force-pushed the test/conformance-cancel-interaction-scenarios branch from a69729e to 9d87c3c Compare August 19, 2026 23:39
@brentrager
brentrager merged commit 85d800e into main Aug 19, 2026
9 checks passed
@brentrager
brentrager deleted the test/conformance-cancel-interaction-scenarios branch August 19, 2026 23:48
brentrager added a commit that referenced this pull request Aug 20, 2026
Mirrors the Rust reference in the preceding commit. Go, Python and .NET each
kept the declared render capabilities in a per-connection map on the dispatcher
(`FrameDispatcher.supports` / `_session_supports` / `_sessionSupports`), which a
reconnect wipes and which nothing ever pruned. TypeScript already routed the
value through its `SessionStore`, but stored it on the SESSION record — and a
resume mints a new session, so it started empty just the same.

Each port now persists it per CONVERSATION through the mechanism its own store
already used for conversation-scoped facts, rather than a fifth invented one:
Go adds `SetConversationSupports` beside `SetCurrentStep`, Python adds
`get/set_client_supports` beside the workflow-step pointer, TypeScript adds a
`convSupports` map beside `convOwner`/`convOrg`, .NET adds
`Get/SetClientSupportsAsync` beside `Get/SetWorkflowStepAsync`. Every
implementation of each store interface is updated, in-memory and Postgres.

The per-connection maps are DELETED rather than kept as caches, so there is one
source of truth and the leak goes with them.

Distinguishing an omitted `supports` from an explicit `[]` is load-bearing —
omitted inherits, `[]` replaces — and three of the four collapsed them. Go's
frame field becomes `*[]string` (json.Unmarshal gives nil for both), .NET's
`ParseSupports` returns `IReadOnlyList<string>?`, and the TS stores stopped
dropping an empty list on the floor.

Correction to the previous commit message: it claimed the four ports never parse
`supports` and host no interactions framework. That was true of the commit this
work branched from and is false on current main — #505/#509/#513/#520 landed the
framework in Go, TypeScript, Python and .NET in the meantime. The pearl's
original diagnosis was right; the rebase is what surfaced it.

Each port adds a reconnect test that drives a SECOND, FRESH dispatcher over the
same store — a single dispatcher would pass even with per-connection state — and
each was verified to fail against its own pre-fix code before being kept.

Verified: go build/vet/test + gofmt (go and go/server modules), ruff check +
format + pytest (389), tsc + vitest (390), dotnet build + test (642 across five
assemblies). Postgres-backed suites really ran; Docker was up.
brentrager added a commit that referenced this pull request Aug 21, 2026
* th-13df6d: keep Rich Interactions alive across a reconnect

`supports` — the client render-capability list that gates the entire Rich
Interactions framework — lived only on the session
(`Session.metadata.supports`, read by `AppState::session_capabilities`). A
reconnect IS a resume: the client re-opens the socket and re-issues
`create_conversation_session` with the same `conversationId`, which mints a NEW
session id. So unless the client re-declared `supports` every single time, the
server forgot it could render cards and every interaction kind quietly fell back
to conversational collection — no error, no event, nothing on the wire to
notice. Reconnects are routine (network blips, mobile backgrounding, deploys),
so a shipped feature was degrading in the field with no signal.

The session registry was already the wrong home, and this repo had said so once
before: th-c12df5 moved the workflow step pointer off it for exactly this reason
("this per-pod session map resets on reconnect/pod hop"). `supports` now rides
durable conversation metadata (`clientSupports`) the same way — same
read-modify-write shape as `persist_workflow_step`, same best-effort failure
mode — and a resume that omits the key inherits what the conversation last
declared.

A list the frame DOES declare always wins, including `[]`. That is now how a
text-only channel resuming a rich conversation opts out, so the spec's
`supports` description carries the rule and the generated TS/Go/Python/.NET
types are regenerated from it rather than restating it by hand. The inherit
direction is bounded anyway: a card a client cannot render times out
(`INTERACTION_TIMEOUT`) into the same conversational fallback the gate would
have chosen.

Verified across all five implementations first. The pearl's premise that the
four ports keep `supports` in a per-connection map does not hold — Go,
TypeScript, Python and .NET never parse `supports` at all and host no
interactions framework (`interaction_required` / `submit_interaction` /
`identity_form` appear only in their generated wire types), so there is nothing
there to persist yet. Rust is the only implementation with the behavior, so it
is the only one changed.

`reconnect_resuming_a_conversation_keeps_the_declared_capabilities` covers both
directions and fails on the pre-fix handler (verified by reverting).

* th-13df6d: move `supports` onto the conversation in all four ports

Mirrors the Rust reference in the preceding commit. Go, Python and .NET each
kept the declared render capabilities in a per-connection map on the dispatcher
(`FrameDispatcher.supports` / `_session_supports` / `_sessionSupports`), which a
reconnect wipes and which nothing ever pruned. TypeScript already routed the
value through its `SessionStore`, but stored it on the SESSION record — and a
resume mints a new session, so it started empty just the same.

Each port now persists it per CONVERSATION through the mechanism its own store
already used for conversation-scoped facts, rather than a fifth invented one:
Go adds `SetConversationSupports` beside `SetCurrentStep`, Python adds
`get/set_client_supports` beside the workflow-step pointer, TypeScript adds a
`convSupports` map beside `convOwner`/`convOrg`, .NET adds
`Get/SetClientSupportsAsync` beside `Get/SetWorkflowStepAsync`. Every
implementation of each store interface is updated, in-memory and Postgres.

The per-connection maps are DELETED rather than kept as caches, so there is one
source of truth and the leak goes with them.

Distinguishing an omitted `supports` from an explicit `[]` is load-bearing —
omitted inherits, `[]` replaces — and three of the four collapsed them. Go's
frame field becomes `*[]string` (json.Unmarshal gives nil for both), .NET's
`ParseSupports` returns `IReadOnlyList<string>?`, and the TS stores stopped
dropping an empty list on the floor.

Correction to the previous commit message: it claimed the four ports never parse
`supports` and host no interactions framework. That was true of the commit this
work branched from and is false on current main — #505/#509/#513/#520 landed the
framework in Go, TypeScript, Python and .NET in the meantime. The pearl's
original diagnosis was right; the rebase is what surfaced it.

Each port adds a reconnect test that drives a SECOND, FRESH dispatcher over the
same store — a single dispatcher would pass even with per-connection state — and
each was verified to fail against its own pre-fix code before being kept.

Verified: go build/vet/test + gofmt (go and go/server modules), ruff check +
format + pytest (389), tsc + vitest (390), dotnet build + test (642 across five
assemblies). Postgres-backed suites really ran; Docker was up.

* th-13df6d: don't float a reconnect to the top of the conversation sidebar

The TypeScript store's capability write also set conversations.updated_at.
That column is the sidebar's recency sort and is bumped when a MESSAGE lands
(appendMessage); a bare reconnect appends nothing, so this floated every
backgrounded tab to the top of the list on resume. The Go, Python and Rust
stores all leave it alone — a parity-relevant choice, now commented as one.

* th-13df6d: correct the .NET store's divergence note — the data is NOT in the shared place

The <remarks> claimed 'the DATA lives in the shared place under the shared keys
either way'. Only the key names are shared: this store writes
conversation_sessions.metadata, the other four write conversations.metadata_json
(rust/adapters/postgres/src/lib.rs:618). A note that asserts the divergence away
is worse than no note, since it is exactly what someone would rely on before
pointing both at one database.

States the real divergence, that nothing gates it (knownDivergences was retired
in #523, and a test pinning the current table would lock the drift in), and that
moving ONE key would split this store's three across two tables — strictly worse.
Unification tracked as th-52becd.
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