fix: don't switch LLM profile before the conversation UUID exists (avoids 422) (#14900)

This commit is contained in:
Alona
2026-06-29 14:08:47 -05:00
committed by GitHub
parent e6fe5057fc
commit 83ce412d7e
4 changed files with 31 additions and 2 deletions
@@ -13,6 +13,7 @@ import type { LlmProfileSummary } from "#/api/settings-service/profiles-service.
const mockUseLlmProfiles = vi.hoisted(() => vi.fn());
const mockUseActiveConversation = vi.hoisted(() => vi.fn());
const mockSwitchAndLog = vi.hoisted(() => vi.fn());
const mockConversationId = vi.hoisted(() => ({ value: "conv-1" }));
const mockModelStore = vi.hoisted(() => ({
activeProfileByConversation: {} as Record<string, string>,
}));
@@ -34,7 +35,7 @@ vi.mock("#/hooks/mutation/use-switch-llm-profile-and-log", () => ({
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "conv-1" }),
useConversationId: () => ({ conversationId: mockConversationId.value }),
}));
vi.mock("#/stores/model-store", () => ({
@@ -102,6 +103,7 @@ const setupHooks = (
describe("SwitchProfileButton", () => {
beforeEach(() => {
mockSwitchAndLog.mockReset();
mockConversationId.value = "conv-1";
});
afterEach(() => {
@@ -205,6 +207,13 @@ describe("SwitchProfileButton", () => {
expect(mockSwitchAndLog).toHaveBeenCalledWith("conv-1", "default");
});
it("is disabled while the conversation id is still the `task-` placeholder", () => {
mockConversationId.value = "task-abc-123";
setupHooks({ conversationModel: "openai/gpt-5" });
renderButton();
expect(screen.getByTestId("switch-profile-button")).toBeDisabled();
});
it("does not call switchAndLog when the already-active profile is selected", async () => {
const user = userEvent.setup();
setupHooks({ conversationModel: "openai/gpt-5" }); // gpt-5 is active
@@ -52,6 +52,17 @@ describe("useSwitchLlmProfileAndLog", () => {
);
});
it("no-ops for a `task-` placeholder id (no call, no error toast) during startup", async () => {
const { result } = renderTestHook();
act(() => result.current.switchAndLog("task-abc-123", "gpt-5"));
// Give any pending mutation a tick to fire (it shouldn't).
await new Promise((r) => {
setTimeout(r, 0);
});
expect(mockSwitchProfile).not.toHaveBeenCalled();
expect(mockDisplayErrorToast).not.toHaveBeenCalled();
});
it("records a switch entry on success, anchored to the latest rendered v1 event", async () => {
useEventStore.setState({
events: [],
@@ -25,6 +25,10 @@ export function SwitchProfileButton() {
const profiles = data?.profiles ?? [];
const conversationModel = conversation?.llm_model ?? null;
// Still starting: the id is the placeholder `task-<uuid>`, not the real
// conversation UUID yet, so a switch can't be persisted (would 422).
const isStarting = conversationId.startsWith("task-");
// Resolve the active profile, most-authoritative source first:
// 1. A switch the user made this session (recorded by name, so it's exact
// even when several profiles share a model string, e.g. SaaS managed
@@ -77,7 +81,7 @@ export function SwitchProfileButton() {
<button
type="button"
onClick={handleClick}
disabled={isPending}
disabled={isPending || isStarting}
data-testid="switch-profile-button"
title={activeProfileModel ?? undefined}
aria-haspopup="menu"
@@ -17,6 +17,11 @@ export function useSwitchLlmProfileAndLog() {
// Stable identity so the /model interceptor's outer useCallback doesn't bust each render.
const switchAndLog = useCallback(
(conversationId: string, profileName: string) => {
// During chat startup the id is the placeholder `task-<uuid>` (the real
// conversation UUID doesn't exist yet); the switch endpoint types its
// path param as a UUID and would 422. No-op until the UUID is live.
if (conversationId.startsWith("task-")) return;
const last = getRenderedV1Events(useEventStore.getState().uiEvents).at(
-1,
);