fix(chat): ignore response IDs from previous model selections

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mubashir Osmani 2026-10-07 18:56:43 +00:00
parent 08b21713b3
commit 9530fbe2bf
2 changed files with 47 additions and 2 deletions

View file

@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatHistory } from "@/components/chat/useChatHistory";
import ChatConversationPage from "./page";
@ -154,6 +154,44 @@ describe("/ui/chat request metrics", () => {
]);
});
it.each([false, true])("ignores late response IDs after model changes ($0)", async (switchBack) => {
vi.mocked(fetchAvailableModels).mockResolvedValue([
{ model_group: "gpt-5.4-mini", providers: ["openai"] },
{ model_group: "second-model", providers: ["openai"] },
]);
const firstCompletion = Promise.withResolvers<void>();
mockMakeOpenAIResponsesRequest.mockImplementationOnce(async (...args: unknown[]) => {
await firstCompletion.promise;
(args[1] as (role: string, delta: string) => void)("assistant", "First answer");
(args[15] as (id: string) => void)("response-first");
});
mockMakeOpenAIResponsesRequest.mockResolvedValue(undefined);
await sendOneMessage();
fireEvent.click(screen.getByRole("button", { name: /gpt-5\.4-mini/ }));
fireEvent.click(await screen.findByRole("button", { name: "second-model" }));
if (switchBack) {
fireEvent.click(screen.getByRole("button", { name: "second-model" }));
fireEvent.click(await screen.findByRole("button", { name: "gpt-5.4-mini" }));
}
await act(async () => firstCompletion.resolve());
expect(await screen.findByText("First answer")).toBeVisible();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Follow up" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeEnabled());
fireEvent.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => expect(mockMakeOpenAIResponsesRequest).toHaveBeenCalledTimes(2));
const second = mockMakeOpenAIResponsesRequest.mock.calls[1];
expect(second[2]).toBe(switchBack ? "gpt-5.4-mini" : "second-model");
expect(second[14]).toBeNull();
expect(second[0]).toEqual([
{ role: "user", content: "How much did this cost?" },
{ role: "assistant", content: "First answer" },
{ role: "user", content: "Follow up" },
]);
});
it("renders latency, TTFT, token counts and cost reported for the assistant turn", async () => {
mockMakeOpenAIResponsesRequest.mockImplementation(async (...args: unknown[]) => {
const updateTextUI = args[1] as (role: string, delta: string) => void;

View file

@ -77,6 +77,7 @@ export default function ChatConversationPage() {
const [modelSearchText, setModelSearchText] = useState("");
const [responsesSessionId, setResponsesSessionId] = useState<string | null>(null);
const responsesSessionGenerationRef = useRef(0);
const [prevConversationIdForSessionReset, setPrevConversationIdForSessionReset] = useState(activeConversationId);
const [isStreaming, setIsStreaming] = useState(false);
const [inputText, setInputText] = useState("");
@ -129,6 +130,7 @@ export default function ChatConversationPage() {
}
const selectModel = useCallback((model: string) => {
responsesSessionGenerationRef.current += 1;
setSelectedModel(model);
setResponsesSessionId(null);
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, model);
@ -141,6 +143,7 @@ export default function ChatConversationPage() {
const trimmed = text.trim();
if (!trimmed || !selectedModel || isStreaming) return;
const model = selectedModel;
const sessionGeneration = responsesSessionGenerationRef.current;
setInputText("");
let convId = activeConversationId;
@ -213,7 +216,11 @@ export default function ChatConversationPage() {
undefined,
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
previousResponseId,
(id: string) => setResponsesSessionId(id),
(id: string) => {
if (responsesSessionGenerationRef.current === sessionGeneration) {
setResponsesSessionId(id);
}
},
(event: MCPEvent) => {
// Accumulate locally only — persisted once in finally to avoid
// one full localStorage write per MCP event during streaming.