Skip to content

th-13df6d: keep Rich Interactions alive across a reconnect - #526

Merged
brentrager merged 4 commits into
mainfrom
th-13df6d-supports-session
Aug 21, 2026
Merged

th-13df6d: keep Rich Interactions alive across a reconnect#526
brentrager merged 4 commits into
mainfrom
th-13df6d-supports-session

Conversation

@brentrager

@brentrager brentrager commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Note on the first revision of this PR. It was written against a checkout that predated #505/#509/#513/#520, where Rich Interactions were still Rust-only, and it reported that the four ports "never parse supports". That was true of that base and is false on current main. After rebasing, the pearl's original diagnosis is exactly right: all four ports keep supports per-connection. The PR now fixes all five.

Problem

supports — the client render-capability list declared on create_conversation_session — gates the entire Rich Interactions framework: a kind whose capability is declared parks the turn and emits interaction_required (a card); a kind whose capability is missing degrades to a conversational fallback.

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 on a new dispatcher. Every implementation kept the capability set somewhere that destroys:

where supports lived survives a reconnect?
Rust Session.metadata.supports — the per-pod session registry (handler.rs:503, read state.rs:735) no — a resume mints a new session id
Go FrameDispatcher.supports map + mutex (go/server/dispatcher.go:80), dispatcher built per connection (server.go:349) no — and never pruned
Python self._session_supports (dispatcher.py:91, comment literally says "connection-local") no — and never pruned
.NET _sessionSupports ConcurrentDictionary (FrameDispatcher.cs:54, "Per-connection render capabilities") no — and never pruned
TypeScript already store-backed, but on the session record (sessionStore.ts:87) no — a resume mints a new session

So unless the client re-declared supports on every reconnect, the server forgot it could render cards and every interaction kind quietly fell back — 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 was already the wrong home, and the repo had said so once — th-c12df5 moved the workflow step pointer off it for exactly this reason, leaving a comment at rust/smooth-operator-server/src/state.rs:531:

this per-pod session map resets on reconnect/pod hop, which froze the workflow on its first step … nothing should read the attempt count off a session again.

Fix

supports now lives on the conversation in all five, via whatever conversation-scoped mechanism each store already had — no fifth invented pattern:

  • RustclientSupports on conversation metadata, read-modify-write, mirroring persist_workflow_step (same best-effort failure mode).
  • GoSetConversationSupports on the SessionStore interface beside SetCurrentStep; conversation-keyed map in memory, conversations.metadata_json in Postgres; carried onto StoredSession.Supports on resume and on load.
  • Pythonget/set_client_supports beside the workflow-step pointer; same || merge / single-key delete in Postgres.
  • TypeScriptconvSupports map beside convOwner/convOrg; jsonb_set inside the existing transaction in Postgres.
  • .NETGet/SetClientSupportsAsync beside Get/SetWorkflowStepAsync.

Every implementation of every store interface is updated (in-memory + Postgres, plus a test decorator in .NET). The per-connection maps are deleted, not kept as caches, so there is one source of truth and the leak goes with them.

A list the frame does declare always wins, including [] — that is how a text-only channel resuming a rich conversation opts out, and the opt-out is durable so the next omitting reconnect cannot resurrect the old capabilities. Because [] and an absent key now mean different things, three ports had to stop collapsing them: Go's frame field becomes *[]string (json.Unmarshal gives nil for both), .NET's ParseSupports returns IReadOnlyList<string>?, and both TS stores stopped dropping an empty list.

The rule is written into the supports description in spec/actions/create-conversation-session.schema.json — the source of truth — and the TS/Go/Python/.NET wire types are regenerated from it rather than restating it by hand.

The inherit direction is bounded either way: a card a client cannot render times out (INTERACTION_TIMEOUT) into the same conversational fallback the gate would have chosen.

Tests — each proven to fail without its own fix

Every 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, so that detail is the test. All four assertions from the Rust test are mirrored: declare → omit-on-resume inherits → explicit [] opts out → a further omitting reconnect stays empty.

Each was verified by temporarily reverting only the production edit:

  • Rust tests/interactions.rsa reconnect that omits 'supports' inherits the conversation's declared capabilitiesFAILED. 5 passed; 1 failed
  • Go conversations_test.go:261a reconnect omitting 'supports' lost the conversation's capabilities: map[]; a second, independent revert (dropping the GetSession read-through) fails the [] opt-out assertion, so both halves are load-bearing
  • TypeScript test/supports-reconnect.test.tsexpected [] to include 'identity_form', and separately on the Postgres path
  • Python tests/test_supports_reconnect.pyassert None == ['identity_form'], asserted on the capabilities handed to TurnRunner (not on the store), so persisting-but-not-reading still fails
  • .NET integration-tests/SubmitInteractionTests.cs:519 — four real WebSocket connections against one in-process host; asserts the actual rich-vs-fallback branch (a parked card, then the cannot display choice chips directive), not the storage write

Postgres-backed suites really ran (Docker was up), including a TS assertion that a sibling workflowCurrentStepId survives the jsonb_set.

Full suites green: Rust smooai-smooth-operator-server 285/35 binaries + clippy --all-targets -D warnings + cargo fmt; Go build/vet/test/gofmt across both modules; Python ruff check + format + 389 pytest; TS tsc + 390 vitest; .NET build (0 warnings) + 642 tests across five assemblies.

No conformance scenario — and why

A spec/conformance/scenarios/*.json scenario would hold all five at once, so I checked. It cannot express this today: every runner opens one WebSocket for the whole scenario (reference: python/server/tests/test_scenario_parity.py:110) and drives all steps inside it, and a reconnect is by definition a second connection. Adding a per-step reconnect flag to all five runners is the prerequisite — worth doing, but a separate change from this fix. No knownDivergences markers are used here; nothing is marked as diverging.

Known divergence, pre-existing and unchanged

Rust/Go/Python/TypeScript write clientSupports onto conversations.metadata_json; the .NET store writes it onto session-row metadata, which is that store's documented, deliberate hold for currentStepId and otpVerified too (PostgresSessionStore.cs:16-22). So a single database driven by both Rust and .NET would not share this value — exactly as it already does not share those two keys. Following .NET's own pattern was the smaller change than breaking it for one key; unifying the three is its own piece of work.

Docs

docs/Architecture/Rich Interactions.md gains a "Capabilities survive a reconnect" section covering all five, and the channel matrix now shows SMS/Voice declaring [] rather than omitting.

@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f6700bc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Patch
@smooai/smooth-operator-web-chat-example Patch

Not sure what this means? Click here to learn what changesets are.

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

`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).
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
brentrager force-pushed the th-13df6d-supports-session branch from 572f737 to 4772dcb Compare August 20, 2026 14:52
…ebar

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.
… 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.
@brentrager
brentrager merged commit 2aee08a into main Aug 21, 2026
9 checks passed
brentrager added a commit that referenced this pull request Aug 23, 2026
)

pr-kind-deploy-smoke.yml runs the exact job G6 specified: helm install
into an ephemeral kind cluster, then the protocol smoke against the live
pod. Observed green on #526 today. The entry still claimed we only run
helm lint/template.

A gap doc that is stale in the CLOSED direction is worse than one that
is merely incomplete — it argues for building something that exists. So
the entry now also says to edit this file in the same PR that closes a
gap, and the priority list drops the two items already done.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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