feat(web): version-gated cutover to conversationId on POST /v1/messages + GET /v1/events (LUM-1890 Phase 2)#31944
Merged
Conversation
…es + GET /v1/events Phase 2 of the LUM-1890 wire-format migration. The web client now sends the canonical `conversationId` field to daemons that support it, and falls back to the legacy `conversationKey` for daemons that don't. The version gate adopts the `lib/backwards-compat/` pattern established in #31932: apps/web/src/lib/backwards-compat/conversation-id-wire-field.ts - MIN_VERSION = "0.8.5" - pickConversationIdWireField(): "conversationId" | "conversationKey" The helper reads the version snapshot via `useAssistantIdentityStore.getState()` rather than the `use.version()` hook selector, so it's safe to call from the async `postChatMessage` and `subscribeChatEvents` paths. Semver parsing reuses the shared `@/utils/semver.js` utilities (so behavior matches the existing `useAssistantSupports` hook): pre-release suffixes count as the full patch version, `v` prefix is stripped, unparseable / missing versions fall back to the legacy field. ## Why the gate The web client and the macOS daemon ship on independent release cadences. Users update the daemon by installing a fresh macOS app build; the web is whatever is currently deployed at app.vellum.ai. The web is usually ahead of the local daemon. When we introduce a new wire field we must keep speaking the old field name to daemons that don't know the new one yet. Cutover threshold: daemon >= 0.8.5. Phase 1 (#31922) will ride in 0.8.5; current main is 0.8.4. ## Wire sites updated Two outbound sites in apps/web: - `postChatMessage` (`apps/web/src/domains/chat/api/messages.ts`) — `POST /v1/messages` body. - `subscribeChatEvents` (`apps/web/src/domains/chat/api/stream.ts`) — `GET /v1/events` query. Both now do `[pickConversationIdWireField()]: conversationId` to pick the right wire-field name on each call. ## Tests - `lib/backwards-compat/conversation-id-wire-field.test.ts` — 5 tests covering: unknown version → conversationKey, 0.8.4 / 0.7.0 → conversationKey, 0.8.5 / 0.8.6 / 0.9.0 / 1.0.0 → conversationId, RC builds (0.8.5-rc.1, 0.8.5-beta) → conversationId, unparseable versions → conversationKey. (Exhaustive semver-edge tests already live in `utils.test.ts`.) - `post-chat-message.test.ts` — adds a `postChatMessage wire-field bilingual cutover` describe block (4 tests) verifying the matrix at the integration level (asserting against the actual outbound request body). - `stream.test.ts` — adds 3 bilingual tests mirroring the same matrix for the SSE subscribe query param, and broadens the existing `omits ... query when subscribing to all assistant events` test to confirm both wire fields are absent (was only checking `conversationKey`). - Existing tests get `clearIdentity()` in `beforeEach`/`afterEach` so the version-gate defaults to the conservative legacy path. ## Out of scope (this PR) - `event-parser.ts` reads inbound `conversationKey` from event payloads; daemon still emits this in deep-link metadata. - `routes.tsx` URL-redirect that translates legacy `?conversationKey=` URLs in-app to `?conversationId=` is a URL semantics shim, not a wire-format concern. - Inspector / event-bus / type-definition references are inbound or client-internal, not wire-out. ## Out of scope (later phases) - Phase 3: macOS Swift client cutover. - Phase 4: daemon drops legacy `conversationKey` from the wire. Ships after macOS catches up. The version gate above means newer web clients gracefully downshift against older daemons; the breaking direction (older web talking to a Phase-4 daemon) we control via the deploy gate. Refs LUM-1890.
acd5761 to
0a9217a
Compare
dvargasfuertes
approved these changes
May 24, 2026
dvargasfuertes
pushed a commit
that referenced
this pull request
May 25, 2026
…rmat migration (#31960) Phase 1 (#31922) made the daemon bilingual; Phase 2 (#31944) cut web over to conversationId with a 0.8.5+ version gate. Phase 3 (macOS Swift) and Phase 4 (drop conversationKey) are explicitly out of scope — desktop is moving to Electron-of-web and the bilingual routes will continue to support both fields indefinitely for non-vellum channel adapters (Telegram, WhatsApp, integrations). Docs: - docs/internal-reference.md: GET /v1/assistants/:id/messages and GET /v1/events now document conversationId as the preferred field and conversationKey as the legacy alias for external channel adapters. Curl example flipped to ?conversationId. - apps/web/docs/CONVENTIONS.md: rewrote Rule 1 to point at lib/backwards-compat/conversation-id-wire-field.ts and remove the 'incremental cleanup' framing — wire-format work is locked in. Rule 3 reframed: conversationKey is retained for external channel adapters, web uses conversationId. - apps/web/docs/EVENT_BUS.md: bus consumers filter on payload.conversationId, not payload.conversationKey (events emit conversationId at the envelope level). - assistant/ARCHITECTURE.md: mermaid SSE_ROUTE node lists both query params. Code (queued follow-ups from #31922 review): - conversation-management-routes.ts: dropped a 7-line narration block inside handleCreateConversation explaining id vs conversationKey (already documented in the Zod schema). - conversation-management-routes.ts: tightened the conversationKey Zod response description from 'Echo of the optional external key supplied by the client (or the value the daemon minted when omitted).' to 'Echo of the optional external key supplied by the client.' OpenAPI regenerated (single-line description delta). Co-authored-by: vellum-apollo-bot[bot] <242025090+vellum-apollo-bot[bot]@users.noreply.github.com>
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.
Summary
Phase 2 of the LUM-1890 wire-format migration. The web client now sends the canonical
conversationIdfield to daemons that support it, and falls back to the legacyconversationKeyfor daemons that don't.Phase 1 (#31922, merged at
0fbf523aa9) made the daemon bilingual —POST /v1/messagesandGET /v1/eventsaccept eitherconversationKey(legacy external-key lookup) orconversationId(canonical internal-id lookup). This PR cuts the web client over to the canonical field with a version gate.The version gate adopts the
lib/backwards-compat/pattern established in #31932 — gates live in one directory, reuse the shared@/utils/semver.jsutilities, and document theirMIN_VERSIONper feature area.Why the gate
The web client and the macOS daemon ship on independent release cadences. Users update the daemon by installing a fresh macOS app build; the web is whatever is currently deployed at app.vellum.ai. The web is usually ahead of the local daemon. When we introduce a new wire field, the web must keep speaking the old field name to daemons that don't know the new one yet.
Cutover threshold: daemon
>= 0.8.5. Phase 1 will ride in 0.8.5 (currentmainis 0.8.4).What changed
New gate —
apps/web/src/lib/backwards-compat/conversation-id-wire-field.tsReads the version snapshot via
useAssistantIdentityStore.getState()rather than theuse.version()hook selector, so it's safe to call from the non-hook async paths (postChatMessage,subscribeChatEvents). Semver parsing reuses@/utils/semver.jsso behavior matches the existinguseAssistantSupportshook used byflag-query-freshness: pre-release suffixes count as the full patch (0.8.5-rc.1→ supported),vprefix stripped, unparseable / missing versions fall back to the legacy field.Outbound wire sites
Two — both now do
[pickConversationIdWireField()]: conversationIdto pick the right wire-field name on each call:postChatMessage(messages.ts) —POST /v1/messagesbody.subscribeChatEvents(stream.ts) —GET /v1/eventsquery.Tests
conversation-id-wire-field.test.ts(new, 5 tests): unknown → conversationKey, 0.8.4 / 0.7.0 → conversationKey, 0.8.5 / 0.8.6 / 0.9.0 / 1.0.0 → conversationId, RC builds (0.8.5-rc.1,0.8.5-beta) → conversationId, unparseable → conversationKey. Exhaustive semver-edge tests already live inutils.test.ts.post-chat-message.test.ts— addspostChatMessage wire-field bilingual cutoverdescribe (4 tests) covering the matrix at the integration level (asserting against the actual outbound request body). AddsclearIdentity()inbeforeEach/afterEachso the existing block defaults to the legacy path.stream.test.ts— adds 3 bilingual tests mirroring the same matrix for the SSE subscribe query param. Broadens the existingomits conversationKey query …test to confirm both wire fields are absent.Out of scope (this PR)
event-parser.tsreadsdata.conversationKeyfrom inbound events; the daemon still emits this in deep-link metadata.routes.tsxURL-redirect translating legacy?conversationKey=URLs in-app to?conversationId=is a URL semantics shim, not a wire-format concern.conversationKeyare inbound or client-internal.Out of scope (later phases)
conversationKeyfrom the wire. Ships after macOS catches up. The version gate above means newer web clients gracefully downshift against older daemons; the breaking direction (older web talking to a Phase-4 daemon) we control via the deploy gate.Validation
bun run typecheck— cleanbun run lint(touched files) — cleanbun test src/domains/chat/api/ src/lib/backwards-compat/— 232/232 passPending follow-up from Phase 1
After approving #31922, two editorial comments were left on
conversation-management-routes.ts. Both queued innotes/pending-followups.md; they'll ride on the next PR that touches that file. This PR only modifiesapps/web/, so they don't fold in here.Refs LUM-1890.