Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/src/domains/chat/utils/debug-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -719,6 +723,7 @@ describe("installVellumDebugApi", () => {
expect(typeof root?.flags?.toggleTranscriptScrollController).toBe(
"function",
);
expect(typeof root?.flags?.impersonateVersion).toBe("function");
uninstall();
});

Expand Down
20 changes: 17 additions & 3 deletions apps/web/src/domains/chat/utils/debug-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> {
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
127 changes: 127 additions & 0 deletions apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
104 changes: 104 additions & 0 deletions apps/web/src/lib/backwards-compat/impersonate-version-flag.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 15 additions & 2 deletions apps/web/src/stores/assistant-identity-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,7 +42,10 @@ const useAssistantIdentityStoreBase = create<AssistantIdentityStore>(
(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 }),
}),
);
Expand Down