diff --git a/apps/web/src/domains/chat/utils/debug-api.test.ts b/apps/web/src/domains/chat/utils/debug-api.test.ts index 6e24b346c98..cc8ba51f059 100644 --- a/apps/web/src/domains/chat/utils/debug-api.test.ts +++ b/apps/web/src/domains/chat/utils/debug-api.test.ts @@ -697,13 +697,17 @@ type DebugWindow = Window & { _vellumDebug?: { chat?: unknown; events?: { getClients: unknown; getEvents: unknown }; - flags?: { toggleTranscriptScrollController?: (v?: boolean) => boolean }; + flags?: { + toggleTranscriptScrollController?: (v?: boolean) => boolean; + impersonateVersion?: (v?: string | null) => string | null; + }; other?: unknown; }; }; const makeFlagsApi = () => ({ toggleTranscriptScrollController: (_value?: boolean): boolean => false, + impersonateVersion: (_value?: string | null): string | null => null, }); describe("installVellumDebugApi", () => { @@ -719,6 +723,7 @@ describe("installVellumDebugApi", () => { expect(typeof root?.flags?.toggleTranscriptScrollController).toBe( "function", ); + expect(typeof root?.flags?.impersonateVersion).toBe("function"); uninstall(); }); diff --git a/apps/web/src/domains/chat/utils/debug-api.ts b/apps/web/src/domains/chat/utils/debug-api.ts index ebe4b615a43..22cb6d6e3ea 100644 --- a/apps/web/src/domains/chat/utils/debug-api.ts +++ b/apps/web/src/domains/chat/utils/debug-api.ts @@ -44,6 +44,7 @@ import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; import type { ReconcileActiveConversationResult } from "@/domains/chat/hooks/use-message-reconciliation"; import { setTranscriptScrollControllerEnabled } from "@/domains/chat/transcript/transcript-scroll-flag"; +import { setImpersonatedAssistantVersion } from "@/lib/backwards-compat/impersonate-version-flag"; import { classifyScrollPosition, type TranscriptHandle, @@ -704,6 +705,18 @@ export interface VellumDebugFlagsApi { * Persists to localStorage and reloads the page. Pass `true`/`false` * to force a specific value; omit to flip the current value. */ toggleTranscriptScrollController(value?: boolean): boolean; + /** Override the assistant's reported version for every version-gated + * code path in the web client (the wire-field cutover, the + * server-mint gate, `useAssistantSupports`, …). Persists to + * localStorage and reloads. + * + * - `impersonateVersion("0.8.6")` — set to that version + reload. + * - `impersonateVersion(null)` — clear override + reload. + * - `impersonateVersion()` — log + return current value + * (no reload, no mutation). + * + * Returns the value in effect after the call. */ + impersonateVersion(value?: string | null): string | null; } interface VellumDebugRoot extends Record { @@ -730,9 +743,9 @@ declare global { * - `api` — the full `@vellumai/assistant-api` namespace, so a developer * can pull canonical SSE schemas (`RelationshipStateUpdatedEventSchema`, …) * out of the shipped bundle from the console. - * - `flags` — dev-toggleable feature flags (currently: - * `toggleTranscriptScrollController`). Stable singleton; pure - * module exports backed by localStorage. + * - `flags` — dev-toggleable feature flags + * (`toggleTranscriptScrollController`, `impersonateVersion`). + * Stable singleton; pure module exports backed by localStorage. * * Consolidating these into one installer guarantees they're set at the * same time and torn down together, so DevTools never sees one namespace @@ -820,6 +833,7 @@ export function useChatDebugApi(refs: ChatDebugRefs): void { const api = createChatDebugApi(stableRefs); const flagsApi: VellumDebugFlagsApi = { toggleTranscriptScrollController: setTranscriptScrollControllerEnabled, + impersonateVersion: setImpersonatedAssistantVersion, }; const uninstall = installVellumDebugApi(api, flagsApi); return uninstall; diff --git a/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts b/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts new file mode 100644 index 00000000000..2df08623454 --- /dev/null +++ b/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for the assistant version-impersonation dev flag. + * + * Covers: + * - localStorage round-trip via get/set + * - inspect-only mode (`undefined` arg) is non-destructive and + * does not reload + * - explicit `null`/empty-string clears + * - assistant identity store funnels impersonated value through + * `setIdentity` regardless of the value the caller passed + * - real version comes back after the override is cleared + */ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +import { + getImpersonatedAssistantVersion, + setImpersonatedAssistantVersion, +} from "@/lib/backwards-compat/impersonate-version-flag"; +import { useAssistantIdentityStore } from "@/stores/assistant-identity-store"; + +const STORAGE_KEY = "vellumDebug.flags.impersonateAssistantVersion"; + +describe("impersonate-version-flag", () => { + let originalReload: typeof window.location.reload; + let reloadCalls: number; + + beforeEach(() => { + window.localStorage.removeItem(STORAGE_KEY); + useAssistantIdentityStore.getState().clearIdentity(); + reloadCalls = 0; + // location.reload is non-configurable in jsdom — replace at the + // descriptor level so we can count calls without actually + // reloading the test process. + originalReload = window.location.reload; + Object.defineProperty(window.location, "reload", { + configurable: true, + value: mock(() => { + reloadCalls += 1; + }), + }); + }); + + afterEach(() => { + window.localStorage.removeItem(STORAGE_KEY); + useAssistantIdentityStore.getState().clearIdentity(); + Object.defineProperty(window.location, "reload", { + configurable: true, + value: originalReload, + }); + }); + + test("get returns null when no override is set", () => { + expect(getImpersonatedAssistantVersion()).toBeNull(); + }); + + test("set persists a version string and triggers reload", () => { + const result = setImpersonatedAssistantVersion("0.8.6"); + expect(result).toBe("0.8.6"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("0.8.6"); + expect(reloadCalls).toBe(1); + expect(getImpersonatedAssistantVersion()).toBe("0.8.6"); + }); + + test("explicit null clears and reloads", () => { + window.localStorage.setItem(STORAGE_KEY, "0.9.0"); + const result = setImpersonatedAssistantVersion(null); + expect(result).toBeNull(); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(reloadCalls).toBe(1); + expect(getImpersonatedAssistantVersion()).toBeNull(); + }); + + test("empty string clears (same as null)", () => { + window.localStorage.setItem(STORAGE_KEY, "0.9.0"); + const result = setImpersonatedAssistantVersion(""); + expect(result).toBeNull(); + expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + test("undefined arg is inspect-only — no reload, no mutation", () => { + window.localStorage.setItem(STORAGE_KEY, "0.8.6"); + const result = setImpersonatedAssistantVersion(); + expect(result).toBe("0.8.6"); + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("0.8.6"); + expect(reloadCalls).toBe(0); + }); + + test("undefined arg returns null when nothing is set", () => { + const result = setImpersonatedAssistantVersion(); + expect(result).toBeNull(); + expect(reloadCalls).toBe(0); + }); + + test("identity store substitutes impersonated version on setIdentity", () => { + // Stash an impersonation directly (bypassing reload) and verify + // that every setIdentity call funnels through the override — + // the caller's `version` arg is ignored when the flag is set. + window.localStorage.setItem(STORAGE_KEY, "0.8.6"); + + useAssistantIdentityStore.getState().setIdentity("Vel", "0.7.0"); + expect(useAssistantIdentityStore.getState().version).toBe("0.8.6"); + + useAssistantIdentityStore.getState().setIdentity("Vel", null); + expect(useAssistantIdentityStore.getState().version).toBe("0.8.6"); + + useAssistantIdentityStore.getState().setIdentity("Vel", "0.9.99"); + expect(useAssistantIdentityStore.getState().version).toBe("0.8.6"); + }); + + test("identity store passes through real version when no override is set", () => { + useAssistantIdentityStore.getState().setIdentity("Vel", "0.7.0"); + expect(useAssistantIdentityStore.getState().version).toBe("0.7.0"); + + useAssistantIdentityStore.getState().setIdentity("Vel", null); + expect(useAssistantIdentityStore.getState().version).toBeNull(); + }); + + test("clearing the override restores real-version passthrough on next setIdentity", () => { + window.localStorage.setItem(STORAGE_KEY, "0.8.6"); + useAssistantIdentityStore.getState().setIdentity("Vel", "0.7.0"); + expect(useAssistantIdentityStore.getState().version).toBe("0.8.6"); + + window.localStorage.removeItem(STORAGE_KEY); + useAssistantIdentityStore.getState().setIdentity("Vel", "0.7.0"); + expect(useAssistantIdentityStore.getState().version).toBe("0.7.0"); + }); +}); diff --git a/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts b/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts new file mode 100644 index 00000000000..b6f3e0b759f --- /dev/null +++ b/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts @@ -0,0 +1,104 @@ +// Dev flag: override the assistant's reported version so every +// version-gated code path in the web client behaves as if it were +// talking to that assistant build. +// +// Lives alongside `pickConversationIdWireField`, +// `supportsServerMintedConversation`, `useAssistantSupports`, etc. so +// the override and the gates it influences are co-located. +// +// Mechanism: `setImpersonatedAssistantVersion(...)` writes to +// localStorage and reloads. On the next page load, the assistant +// identity store's `setIdentity` action reads this flag and substitutes +// the impersonated version on every write — initial fetch, SSE +// `identity_changed`, and the optimistic onboarding seed all funnel +// through `setIdentity`, so the override is uniformly applied without +// any consumer needing to know about it. +// +// Reload-on-change matches the DX of `transcript-scroll-flag.ts`: +// • some consumers cache version-derived constants at module load +// (e.g. anything that wants a stable identity across re-renders); +// • SSE handlers re-read from the store on every event but a stale +// in-memory copy of `version` on a long-lived listener would +// surprise a developer mid-session. +// Reloading guarantees a uniform world after the flag flips. +// +// Surface (exposed under `window._vellumDebug.flags`): +// +// impersonateVersion("0.8.6") — set + reload +// impersonateVersion(null) — clear + reload +// impersonateVersion() — log + return current value, no reload + +const STORAGE_KEY = "vellumDebug.flags.impersonateAssistantVersion"; + +/** + * Read the impersonated version synchronously. Safe to call at any + * time, including from inside the assistant identity store's + * `setIdentity` action (which is the primary consumer). + * + * Returns `null` when no override is set, the storage key is missing, + * or localStorage access throws (private browsing / sandboxed iframes). + */ +export function getImpersonatedAssistantVersion(): string | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + return raw && raw.length > 0 ? raw : null; + } catch { + return null; + } +} + +/** + * Set or clear the impersonated assistant version. + * + * - `value: string` (non-empty) — persist and reload so all version + * gates re-evaluate against the impersonated value. + * - `value: null` — clear the override and reload back to the real + * assistant-reported version. + * - `value: undefined` — inspect-only. Log + return the current + * value. No mutation, no reload. + * + * Returns the value that will be in effect after the call (post-reload + * for set/clear, current for inspect). Note the reload kills the JS + * context, so callers rarely consume the return value on set/clear + * paths — it's documented mainly for tests. + */ +export function setImpersonatedAssistantVersion( + value?: string | null, +): string | null { + if (typeof window === "undefined") return null; + + // Inspect-only branch — explicitly no-op, no reload. + if (value === undefined) { + const current = getImpersonatedAssistantVersion(); + console.info( + `[vellumDebug] impersonateAssistantVersion (current) = ${ + current === null ? "null" : JSON.stringify(current) + }`, + ); + return current; + } + + try { + if (value === null || value === "") { + window.localStorage.removeItem(STORAGE_KEY); + console.info( + "[vellumDebug] impersonateAssistantVersion = null (cleared) — reloading…", + ); + } else { + window.localStorage.setItem(STORAGE_KEY, value); + console.info( + `[vellumDebug] impersonateAssistantVersion = ${JSON.stringify( + value, + )} — reloading…`, + ); + } + } catch { + console.warn( + "[vellumDebug] failed to persist impersonateAssistantVersion flag", + ); + return getImpersonatedAssistantVersion(); + } + window.location.reload(); + return value === "" ? null : value; +} diff --git a/apps/web/src/stores/assistant-identity-store.ts b/apps/web/src/stores/assistant-identity-store.ts index a32ece63d15..980bd9710cd 100644 --- a/apps/web/src/stores/assistant-identity-store.ts +++ b/apps/web/src/stores/assistant-identity-store.ts @@ -4,16 +4,26 @@ * `ChatLayout` writes via `useAssistantIdentityInit` (first load and * assistant-context changes) and reads name/version for the sidebar * header and `PreferencesMenu`. `ChatPage` also writes from its own - * local state when the daemon pushes a fresher identity (SSE + * local state when the assistant pushes a fresher identity (SSE * `identity_changed`) — idempotent with the layout write. * * A Zustand store avoids prop drilling through the React Router * outlet context for simple scalar values. * + * Dev-only version impersonation: `setIdentity` consults + * `getImpersonatedAssistantVersion()` and substitutes its value when + * set. This funnels every real write site (initial identity fetch, + * SSE identity_changed, the optimistic onboarding seed) through the + * same override path so version-gated code (`useAssistantSupports`, + * `pickConversationIdWireField`, `supportsServerMintedConversation`, + * …) sees a uniform impersonated value without any consumer needing + * to know the flag exists. See `lib/backwards-compat/impersonate-version-flag.ts`. + * * @see {@link https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components} */ import { create } from "zustand"; +import { getImpersonatedAssistantVersion } from "@/lib/backwards-compat/impersonate-version-flag"; import { createSelectors } from "@/utils/create-selectors"; interface AssistantIdentityState { @@ -32,7 +42,10 @@ const useAssistantIdentityStoreBase = create( (set) => ({ name: null, version: null, - setIdentity: (name, version) => set({ name, version }), + setIdentity: (name, version) => { + const impersonated = getImpersonatedAssistantVersion(); + set({ name, version: impersonated ?? version }); + }, clearIdentity: () => set({ name: null, version: null }), }), );