Skip to content

feat(web): version-gated cutover to conversationId on POST /v1/messages + GET /v1/events (LUM-1890 Phase 2)#31944

Merged
dvargasfuertes merged 1 commit into
mainfrom
apollo/lum-1890-phase-2-web-cutover
May 24, 2026
Merged

feat(web): version-gated cutover to conversationId on POST /v1/messages + GET /v1/events (LUM-1890 Phase 2)#31944
dvargasfuertes merged 1 commit into
mainfrom
apollo/lum-1890-phase-2-web-cutover

Conversation

@vellum-apollo-bot
Copy link
Copy Markdown
Contributor

@vellum-apollo-bot vellum-apollo-bot Bot commented May 24, 2026

Summary

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.

Phase 1 (#31922, merged at 0fbf523aa9) made the daemon bilingualPOST /v1/messages and GET /v1/events accept either conversationKey (legacy external-key lookup) or conversationId (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.js utilities, and document their MIN_VERSION per 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 (current main is 0.8.4).

What changed

New gate — apps/web/src/lib/backwards-compat/conversation-id-wire-field.ts

const MIN_VERSION = "0.8.5";

export type ConversationIdWireField = "conversationId" | "conversationKey";

export function pickConversationIdWireField(): ConversationIdWireField {
  const version = useAssistantIdentityStore.getState().version;
  if (!version) return "conversationKey";
  const parsed = parseSemver(version);
  const min = parseSemver(MIN_VERSION);
  if (!parsed || !min) return "conversationKey";
  return compareParsed({ ...parsed, pre: null }, min) >= 0
    ? "conversationId"
    : "conversationKey";
}

Reads the version snapshot via useAssistantIdentityStore.getState() rather than the use.version() hook selector, so it's safe to call from the non-hook async paths (postChatMessage, subscribeChatEvents). Semver parsing reuses @/utils/semver.js so behavior matches the existing useAssistantSupports hook used by flag-query-freshness: pre-release suffixes count as the full patch (0.8.5-rc.1 → supported), v prefix stripped, unparseable / missing versions fall back to the legacy field.

Outbound wire sites

Two — both now do [pickConversationIdWireField()]: conversationId to pick the right wire-field name on each call:

  1. postChatMessage (messages.ts)POST /v1/messages body.
  2. subscribeChatEvents (stream.ts)GET /v1/events query.

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 in utils.test.ts.
  • post-chat-message.test.ts — adds postChatMessage wire-field bilingual cutover describe (4 tests) covering the matrix at the integration level (asserting against the actual outbound request body). Adds clearIdentity() in beforeEach/afterEach so 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 existing omits conversationKey query … test to confirm both wire fields are absent.

Out of scope (this PR)

  • event-parser.ts reads data.conversationKey from inbound events; the daemon still emits this in deep-link metadata.
  • routes.tsx URL-redirect translating legacy ?conversationKey= URLs in-app to ?conversationId= is a URL semantics shim, not a wire-format concern.
  • Inspector / event-bus / type-definition references to conversationKey are inbound or client-internal.

Out of scope (later phases)

  • Phase 3: macOS Swift client cutover (6 files).
  • 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.

Validation

  • bun run typecheck — clean
  • bun run lint (touched files) — clean
  • bun test src/domains/chat/api/ src/lib/backwards-compat/232/232 pass

Pending follow-up from Phase 1

After approving #31922, two editorial comments were left on conversation-management-routes.ts. Both queued in notes/pending-followups.md; they'll ride on the next PR that touches that file. This PR only modifies apps/web/, so they don't fold in here.

Refs LUM-1890.

@linear
Copy link
Copy Markdown

linear Bot commented May 24, 2026

LUM-1890

…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.
@vellum-apollo-bot vellum-apollo-bot Bot force-pushed the apollo/lum-1890-phase-2-web-cutover branch from acd5761 to 0a9217a Compare May 24, 2026 21:21
@dvargasfuertes dvargasfuertes merged commit 58f7af6 into main May 24, 2026
7 checks passed
@dvargasfuertes dvargasfuertes deleted the apollo/lum-1890-phase-2-web-cutover branch May 24, 2026 22:01
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>
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