fix: persist per-turn model on chats and queued messages (#24688)

Previously, `chats.last_model_config_id` was not updated when a user
sent a mid-chat message with a different model, and queued messages did
not store their own per-turn model, so promotion ran against whatever
the chat row said at promote time. Chat watch events also did not merge
`last_model_config_id` into the site's root, child, and per-chat
caches, so sidebar labels stayed stale after direct sends and queued
promotions.

- Add nullable `chat_queued_messages.model_config_id`, backfilled from
  `chats.last_model_config_id`. Queued inserts round-trip the effective
  model id at enqueue time.
- In `coderd/x/chatd`, direct sends update `chats.last_model_config_id`
  inside the same transaction that inserts the admitted user message.
  Manual promotion and auto-promotion use the queued row's stored
  `model_config_id`, with a fallback to `chats.last_model_config_id`
for legacy NULL rows during rollout.
`PromoteQueuedOptions.ModelConfigID`
  is now ignored.
- On the site, extract `mergeWatchedChatSummary` and
  `mergeWatchedChatIntoCaches` in `site/src/api/queries/chats.ts` so
  status-change watch events merge `last_model_config_id` into the
  root infinite chat list, the parent-embedded child entry, and the
  per-chat `chatKey(chatId)` cache. `updated_at` guards against stale
  watch payloads clobbering newer cached state, while diff status
  events still merge their PR metadata because they are timestamped
  outside the chat row. Watch timestamps are compared as instants so
  variable fractional precision does not make fresh events look stale.
- Queued promotion validates stored model config IDs before admission.
  Invalid legacy queued IDs fall back to the chat's current model config
  instead of dropping the queued message during auto-promotion.
- Backend and frontend regression coverage added for admission, queue
  promotion (including FIFO across mixed models, legacy NULL fallback,
  and invalid queued model IDs), and chat watch cache merging.

> Mux is acting on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-04-24 15:36:08 +02:00
committed by GitHub
parent a876287d36
commit c7cac9debe
16 changed files with 1580 additions and 182 deletions
+378
View File
@@ -25,6 +25,8 @@ import {
infiniteChats,
interruptChat,
invalidateChatListQueries,
mergeWatchedChatIntoCaches,
mergeWatchedChatSummary,
paginatedChatCostUsers,
pinChat,
promoteChatQueuedMessage,
@@ -1892,6 +1894,382 @@ describe("updateChildInParentCache", () => {
});
});
describe("mergeWatchedChatSummary", () => {
it("merges fresh status updates without clobbering a newer title snapshot", () => {
const cachedChat = makeChat("chat-1", {
status: "pending",
title: "Fresh title",
last_model_config_id: "model-old",
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "running",
title: "Stale title",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
}),
).toMatchObject({
status: "running",
title: "Fresh title",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
});
it("merges last_model_config_id when watched updated_at equals cached updated_at", () => {
const cachedChat = makeChat("chat-1", {
last_model_config_id: "11111111-1111-4111-8111-111111111111",
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
last_model_config_id: "22222222-2222-4222-8222-222222222222",
updated_at: "2025-01-01T00:00:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
}).last_model_config_id,
).toBe("22222222-2222-4222-8222-222222222222");
});
it("compares updated_at values as instants instead of strings", () => {
const cachedChat = makeChat("chat-1", {
status: "pending",
last_model_config_id: "model-old",
updated_at: "2025-01-01T00:00:00.12Z",
});
const watchedChat = makeChat("chat-1", {
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:00:00.1203Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
}),
).toMatchObject({
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:00:00.1203Z",
});
});
it("merges fresh title updates without clobbering a newer status snapshot", () => {
const cachedChat = makeChat("chat-1", {
status: "running",
title: "Fresh title",
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "completed",
title: "Updated title",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "title_change",
}),
).toMatchObject({
status: "running",
title: "Updated title",
});
});
it("merges title updates even when chat updated_at is older", () => {
const cachedChat = makeChat("chat-1", {
status: "running",
title: "Fresh title",
updated_at: "2025-01-01T00:10:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "completed",
title: "Newer generated title",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "title_change",
}),
).toMatchObject({
status: "running",
title: "Newer generated title",
updated_at: "2025-01-01T00:10:00.000Z",
});
});
it("merges fresh diff status updates without clobbering status or title", () => {
const cachedDiffStatus = {
chat_id: "chat-1",
url: "https://example.com/pr/1",
pull_request_state: "open",
pull_request_title: "Old title",
pull_request_draft: false,
changes_requested: false,
additions: 1,
deletions: 2,
changed_files: 3,
refreshed_at: "2025-01-01T00:00:00.000Z",
stale_at: "2025-01-01T01:00:00.000Z",
};
const watchedDiffStatus = {
chat_id: "chat-1",
url: "https://example.com/pr/2",
pull_request_state: "merged",
pull_request_title: "New title",
pull_request_draft: false,
changes_requested: true,
additions: 4,
deletions: 5,
changed_files: 6,
refreshed_at: "2025-01-01T00:05:00.000Z",
stale_at: "2025-01-01T01:05:00.000Z",
};
const cachedChat = makeChat("chat-1", {
status: "running",
title: "Fresh title",
diff_status: cachedDiffStatus,
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "completed",
title: "Stale title",
diff_status: watchedDiffStatus,
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "diff_status_change",
}),
).toMatchObject({
status: "running",
title: "Fresh title",
diff_status: watchedDiffStatus,
});
});
it("merges diff status updates even when chat updated_at is older", () => {
const cachedDiffStatus = {
chat_id: "chat-1",
url: "https://example.com/pr/1",
pull_request_state: "open",
pull_request_title: "Old title",
pull_request_draft: false,
changes_requested: false,
additions: 1,
deletions: 2,
changed_files: 3,
refreshed_at: "2025-01-01T00:00:00.000Z",
stale_at: "2025-01-01T01:00:00.000Z",
};
const watchedDiffStatus = {
chat_id: "chat-1",
url: "https://example.com/pr/2",
pull_request_state: "open",
pull_request_title: "New title",
pull_request_draft: true,
changes_requested: true,
additions: 4,
deletions: 5,
changed_files: 6,
refreshed_at: "2025-01-01T00:10:00.000Z",
stale_at: "2025-01-01T01:10:00.000Z",
};
const cachedChat = makeChat("chat-1", {
status: "running",
title: "Fresh title",
diff_status: cachedDiffStatus,
updated_at: "2025-01-01T00:10:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "completed",
title: "Stale title",
diff_status: watchedDiffStatus,
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "diff_status_change",
}),
).toMatchObject({
status: "running",
title: "Fresh title",
diff_status: watchedDiffStatus,
updated_at: "2025-01-01T00:10:00.000Z",
});
});
it("marks other chats unread on fresh status updates", () => {
const cachedChat = makeChat("chat-1", {
has_unread: false,
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "completed",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
activeChatId: "chat-2",
}).has_unread,
).toBe(true);
});
it("preserves has_unread for the active chat", () => {
const cachedChat = makeChat("chat-1", {
has_unread: false,
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat("chat-1", {
status: "completed",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
mergeWatchedChatSummary(cachedChat, watchedChat, {
eventKind: "status_change",
activeChatId: "chat-1",
}).has_unread,
).toBe(false);
});
});
describe("mergeWatchedChatIntoCaches", () => {
it("merges last_model_config_id into the root list cache and per-chat cache", () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const cachedChat = makeChat(chatId, {
status: "pending",
last_model_config_id: "model-old",
updated_at: "2025-01-01T00:00:00.000Z",
});
const watchedChat = makeChat(chatId, {
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
seedInfiniteChats(queryClient, [cachedChat]);
queryClient.setQueryData(chatKey(chatId), cachedChat);
mergeWatchedChatIntoCaches(queryClient, watchedChat, {
eventKind: "status_change",
});
expect(readInfiniteChats(queryClient)?.[0]).toMatchObject({
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
queryClient.getQueryData<TypesGen.Chat>(chatKey(chatId)),
).toMatchObject({
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
});
it("merges last_model_config_id into the parent-embedded child snapshot and child cache", () => {
const queryClient = createTestQueryClient();
const childId = "child-1";
const cachedChild = makeChat(childId, {
parent_chat_id: "parent-1",
root_chat_id: "parent-1",
status: "pending",
last_model_config_id: "model-old",
updated_at: "2025-01-01T00:00:00.000Z",
});
const parent = makeChat("parent-1", { children: [cachedChild] });
const watchedChild = makeChat(childId, {
parent_chat_id: "parent-1",
root_chat_id: "parent-1",
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
seedInfiniteChats(queryClient, [parent]);
queryClient.setQueryData(chatKey(childId), cachedChild);
mergeWatchedChatIntoCaches(queryClient, watchedChild, {
eventKind: "status_change",
});
expect(readInfiniteChats(queryClient)?.[0].children?.[0]).toMatchObject({
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
queryClient.getQueryData<TypesGen.Chat>(chatKey(childId)),
).toMatchObject({
status: "running",
last_model_config_id: "model-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
});
it("does not let an older watch payload clobber newer cached metadata", () => {
const queryClient = createTestQueryClient();
const chatId = "chat-1";
const cachedChat = makeChat(chatId, {
status: "completed",
title: "Fresh title",
last_model_config_id: "model-new",
workspace_id: "workspace-new",
build_id: "build-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
const staleWatchChat = makeChat(chatId, {
status: "running",
title: "Stale title",
last_model_config_id: "model-old",
workspace_id: "workspace-old",
build_id: "build-old",
updated_at: "2025-01-01T00:00:00.000Z",
});
seedInfiniteChats(queryClient, [cachedChat]);
queryClient.setQueryData(chatKey(chatId), cachedChat);
mergeWatchedChatIntoCaches(queryClient, staleWatchChat, {
eventKind: "status_change",
});
expect(readInfiniteChats(queryClient)?.[0]).toMatchObject({
status: "completed",
title: "Fresh title",
last_model_config_id: "model-new",
workspace_id: "workspace-new",
build_id: "build-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
expect(
queryClient.getQueryData<TypesGen.Chat>(chatKey(chatId)),
).toMatchObject({
status: "completed",
title: "Fresh title",
last_model_config_id: "model-new",
workspace_id: "workspace-new",
build_id: "build-new",
updated_at: "2025-01-01T00:05:00.000Z",
});
});
});
describe("removeChildFromParentInCache", () => {
it("removes the child from its parent's children array", () => {
const queryClient = createTestQueryClient();
+174
View File
@@ -187,6 +187,180 @@ export const removeChildFromParentInCache = (
return found;
};
const parseUpdatedAtInstant = (updatedAt: string) => {
const match = updatedAt.match(/^(.*?)(?:\.(\d+))?(Z|[+-]\d\d:\d\d)$/);
if (!match) {
const epochMs = Date.parse(updatedAt);
return Number.isNaN(epochMs) ? undefined : { epochMs, fractionalNanos: 0 };
}
const [, timestampWithoutFraction, fractionalSeconds = "", timezone] = match;
const epochMs = Date.parse(`${timestampWithoutFraction}${timezone}`);
if (Number.isNaN(epochMs)) {
return undefined;
}
return {
epochMs,
fractionalNanos: Number(fractionalSeconds.slice(0, 9).padEnd(9, "0")),
};
};
const compareUpdatedAtInstants = (a: string, b: string): number => {
const parsedA = parseUpdatedAtInstant(a);
const parsedB = parseUpdatedAtInstant(b);
if (!parsedA || !parsedB) {
return a.localeCompare(b);
}
if (parsedA.epochMs !== parsedB.epochMs) {
return parsedA.epochMs - parsedB.epochMs;
}
return parsedA.fractionalNanos - parsedB.fractionalNanos;
};
type MergeWatchedChatOptions = {
readonly eventKind: TypesGen.ChatWatchEventKind;
readonly activeChatId?: string;
};
// Shallow-compare two ChatDiffStatus objects by their meaningful
// fields, ignoring refreshed_at/stale_at which change on every poll.
const diffStatusEqual = (
a: TypesGen.ChatDiffStatus | undefined,
b: TypesGen.ChatDiffStatus | undefined,
): boolean => {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.url === b.url &&
a.pull_request_state === b.pull_request_state &&
a.pull_request_title === b.pull_request_title &&
a.pull_request_draft === b.pull_request_draft &&
a.changes_requested === b.changes_requested &&
a.additions === b.additions &&
a.deletions === b.deletions &&
a.changed_files === b.changed_files &&
a.pr_number === b.pr_number &&
a.approved === b.approved &&
a.commits === b.commits
);
};
/**
* Merges event-scoped chat fields into a cached summary, using updated_at
* as a stale guard while still adopting the latest DB-backed model config.
*/
export const mergeWatchedChatSummary = (
cachedChat: TypesGen.Chat,
watchedChat: TypesGen.Chat,
{ eventKind, activeChatId }: MergeWatchedChatOptions,
): TypesGen.Chat => {
const isTitleEvent = eventKind === "title_change";
const isStatusEvent = eventKind === "status_change";
const isDiffStatusEvent = eventKind === "diff_status_change";
const updatedAtComparison = compareUpdatedAtInstants(
cachedChat.updated_at,
watchedChat.updated_at,
);
const isFreshEnough = updatedAtComparison <= 0;
const nextStatus =
isFreshEnough && isStatusEvent ? watchedChat.status : cachedChat.status;
// maybeGenerateChatTitle can publish a previously loaded chat snapshot, so
// apply title_change payloads even when the chat summary timestamp is older.
const nextTitle = isTitleEvent ? watchedChat.title : cachedChat.title;
// Diff status freshness is tracked outside chats.updated_at, so apply
// diff_status_change payloads even when the chat summary timestamp is older.
const nextDiffStatus = isDiffStatusEvent
? watchedChat.diff_status
: cachedChat.diff_status;
const nextWorkspaceId = isFreshEnough
? (watchedChat.workspace_id ?? cachedChat.workspace_id)
: cachedChat.workspace_id;
const nextBuildId = isFreshEnough
? (watchedChat.build_id ?? cachedChat.build_id)
: cachedChat.build_id;
// All event types carry the current model config from the DB.
const nextLastModelConfigId = isFreshEnough
? watchedChat.last_model_config_id
: cachedChat.last_model_config_id;
const nextHasUnread =
isFreshEnough && isStatusEvent && watchedChat.id !== activeChatId
? true
: cachedChat.has_unread;
const nextUpdatedAt =
updatedAtComparison > 0 ? cachedChat.updated_at : watchedChat.updated_at;
// Keep updated_at in the no-op guard. This gives up the old streaming
// rerender shortcut so later stale events cannot pass isFreshEnough
// against a timestamp that should already have been superseded.
if (
nextStatus === cachedChat.status &&
nextTitle === cachedChat.title &&
diffStatusEqual(nextDiffStatus, cachedChat.diff_status) &&
nextWorkspaceId === cachedChat.workspace_id &&
nextBuildId === cachedChat.build_id &&
nextLastModelConfigId === cachedChat.last_model_config_id &&
nextHasUnread === cachedChat.has_unread &&
nextUpdatedAt === cachedChat.updated_at
) {
return cachedChat;
}
return {
...cachedChat,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
last_model_config_id: nextLastModelConfigId,
has_unread: nextHasUnread,
updated_at: nextUpdatedAt,
};
};
/**
* Applies the same event-scoped merge and stale guard across the list,
* parent-child, and per-chat caches, covering all three cache layers.
*/
export const mergeWatchedChatIntoCaches = (
queryClient: QueryClient,
watchedChat: TypesGen.Chat,
options: MergeWatchedChatOptions,
) => {
const mergeCachedChat = (cachedChat: TypesGen.Chat) =>
mergeWatchedChatSummary(cachedChat, watchedChat, options);
updateInfiniteChatsCache(queryClient, (chats) => {
let didUpdate = false;
const nextChats = chats.map((chat) => {
if (chat.id !== watchedChat.id) {
return chat;
}
const mergedChat = mergeCachedChat(chat);
if (mergedChat !== chat) {
didUpdate = true;
}
return mergedChat;
});
return didUpdate ? nextChats : chats;
});
updateChildInParentCache(queryClient, mergeCachedChat, watchedChat.id);
queryClient.setQueryData<TypesGen.Chat | undefined>(
chatKey(watchedChat.id),
(cachedChat) => {
if (!cachedChat) {
return cachedChat;
}
return mergeCachedChat(cachedChat);
},
);
};
const getNextOptimisticPinOrder = (queryClient: QueryClient): number => {
let maxPinOrder = 0;
const queries = queryClient.getQueriesData<
+1
View File
@@ -2168,6 +2168,7 @@ export const ChatProviderConfigSources: ChatProviderConfigSource[] = [
export interface ChatQueuedMessage {
readonly id: number;
readonly chat_id: string;
readonly model_config_id?: string;
readonly content: readonly ChatMessagePart[];
readonly created_at: string;
}
+6 -141
View File
@@ -20,6 +20,7 @@ import {
chatsByWorkspaceKeyPrefix,
infiniteChats,
invalidateChatListQueries,
mergeWatchedChatIntoCaches,
pinChat,
prependToInfiniteChatsCache,
readInfiniteChatsCache,
@@ -29,7 +30,6 @@ import {
unarchiveChat,
unpinChat,
updateChatTitle,
updateChildInParentCache,
updateInfiniteChatsCache,
} from "#/api/queries/chats";
import { workspaceById } from "#/api/queries/workspaces";
@@ -55,29 +55,6 @@ import {
chatDetailErrorsEqual,
} from "./utils/usageLimitMessage";
// Shallow-compare two ChatDiffStatus objects by their meaningful
// fields, ignoring refreshed_at/stale_at which change on every poll.
function diffStatusEqual(
a: TypesGen.ChatDiffStatus | undefined,
b: TypesGen.ChatDiffStatus | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
return (
a.url === b.url &&
a.pull_request_state === b.pull_request_state &&
a.pull_request_title === b.pull_request_title &&
a.pull_request_draft === b.pull_request_draft &&
a.changes_requested === b.changes_requested &&
a.additions === b.additions &&
a.deletions === b.deletions &&
a.changed_files === b.changed_files &&
a.pr_number === b.pr_number &&
a.approved === b.approved &&
a.commits === b.commits
);
}
export type { AgentsOutletContext } from "./AgentsPageView";
const AgentsPage: FC = () => {
@@ -557,14 +534,8 @@ const AgentsPage: FC = () => {
exact: true,
});
}
// Scope field updates by event kind so that
// status_change events (which may carry a stale title
// snapshot from before async title generation
// finished) don't clobber a title_change that already
// landed.
const isTitleEvent = chatEvent.kind === "title_change";
const isStatusEvent = chatEvent.kind === "status_change";
const isDiffStatusEvent = chatEvent.kind === "diff_status_change";
// Merge watch payloads by event kind so stale field
// snapshots do not clobber fresher cached metadata.
// Cancel in-flight list and per-chat refetches so
// they cannot overwrite the cache update below with
@@ -606,117 +577,11 @@ const AgentsPage: FC = () => {
prependToInfiniteChatsCache(queryClient, updatedChat);
}
} else {
// Build a field updater shared between root and
// child cache update paths.
const applyFields = (c: TypesGen.Chat): TypesGen.Chat => {
const nextStatus = isStatusEvent ? updatedChat.status : c.status;
const nextTitle = isTitleEvent ? updatedChat.title : c.title;
const nextDiffStatus = isDiffStatusEvent
? updatedChat.diff_status
: c.diff_status;
const nextWorkspaceId =
updatedChat.workspace_id ?? c.workspace_id;
const nextBuildId = updatedChat.build_id ?? c.build_id;
const nextUpdatedAt =
c.updated_at > updatedChat.updated_at
? c.updated_at
: updatedChat.updated_at;
// The server's pubsub path does not compute
// has_unread (it always sends false). For
// status_change events on non-active chats,
// optimistically mark as unread since the
// assistant produced new output.
const nextHasUnread =
isStatusEvent && updatedChat.id !== activeChatIDRef.current
? true
: c.has_unread;
if (
nextStatus === c.status &&
nextTitle === c.title &&
diffStatusEqual(nextDiffStatus, c.diff_status) &&
nextWorkspaceId === c.workspace_id &&
nextBuildId === c.build_id &&
nextHasUnread === c.has_unread
) {
return c;
}
return {
...c,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
updated_at: nextUpdatedAt,
has_unread: nextHasUnread,
};
};
// Try root-level update first.
updateInfiniteChatsCache(queryClient, (chats) => {
let didUpdate = false;
const nextChats = chats.map((c) => {
if (c.id !== updatedChat.id) return c;
const result = applyFields(c);
if (result !== c) didUpdate = true;
return result;
});
return didUpdate ? nextChats : chats;
mergeWatchedChatIntoCaches(queryClient, updatedChat, {
eventKind: chatEvent.kind,
activeChatId: activeChatIDRef.current,
});
// Also update inside parent's children array
// in case the event targets a child chat.
updateChildInParentCache(queryClient, applyFields, updatedChat.id);
}
queryClient.setQueryData<TypesGen.Chat | undefined>(
chatKey(updatedChat.id),
(previousChat) => {
if (!previousChat) {
return previousChat;
}
// Only create a new object if a field actually
// changed. Returning the same reference prevents
// react-query from notifying subscribers, avoiding
// unnecessary re-renders of AgentChatPage during
// streaming when repeated status_change events
// carry the same "running" status.
const nextStatus = isStatusEvent
? updatedChat.status
: previousChat.status;
const nextTitle = isTitleEvent
? updatedChat.title
: previousChat.title;
const nextDiffStatus = isDiffStatusEvent
? updatedChat.diff_status
: previousChat.diff_status;
const nextWorkspaceId =
updatedChat.workspace_id ?? previousChat.workspace_id;
const nextBuildId = updatedChat.build_id ?? previousChat.build_id;
const nextUpdatedAt =
previousChat.updated_at > updatedChat.updated_at
? previousChat.updated_at
: updatedChat.updated_at;
if (
nextStatus === previousChat.status &&
nextTitle === previousChat.title &&
diffStatusEqual(nextDiffStatus, previousChat.diff_status) &&
nextWorkspaceId === previousChat.workspace_id &&
nextBuildId === previousChat.build_id
) {
return previousChat;
}
return {
...previousChat,
status: nextStatus,
title: nextTitle,
diff_status: nextDiffStatus,
workspace_id: nextWorkspaceId,
build_id: nextBuildId,
updated_at: nextUpdatedAt,
};
},
);
});
return ws;
},