From 8ca8b428de12e9ca7472e69c0342bc78900f88c4 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 19 Jul 2026 16:39:56 +0530 Subject: [PATCH 01/12] feat: add updateMessage --- packages/react-headless/src/adapters/types.ts | 1 + packages/react-headless/src/store/createChatStore.ts | 6 ++++++ .../src/components/OpenUIChat/GenUIAssistantMessage.tsx | 8 +++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-headless/src/adapters/types.ts b/packages/react-headless/src/adapters/types.ts index 6c8af8828..d269efc0a 100644 --- a/packages/react-headless/src/adapters/types.ts +++ b/packages/react-headless/src/adapters/types.ts @@ -11,6 +11,7 @@ export interface ThreadStorage { getMessages(threadId: string): Promise; updateThread(thread: Thread): Promise; deleteThread(id: string): Promise; + updateMessage(threadId: string, message: Message): Promise; } // ── Artifact storage (global, cross-thread) ── diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index ef26cfa0c..e955e5894 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -226,6 +226,12 @@ export const createChatStore = (config: CreateChatStoreConfig) => { set((s) => ({ messages: s.messages.map((m) => (m.id === message.id ? message : m)), })); + const threadId = get().selectedThreadId; + if (threadId !== null) { + threadStorage + .updateMessage(threadId, message) + .catch((e) => set((s) => ({ threadError: e }))); + } }, setMessages: (messages: Message[]) => { diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index 7f57b7d76..ffd3482ee 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -9,7 +9,7 @@ import { } from "@openuidev/react-headless"; import type { ActionEvent, Library } from "@openuidev/react-lang"; import { BuiltinActionType, Renderer } from "@openuidev/react-lang"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { separateContentAndContext, wrapContent, @@ -81,6 +81,8 @@ export const GenUIAssistantMessage = ({ // Persist form state into the inline-wrapped message content. The original // header line (which may include `libraryVersion` and telemetry tags emitted // by the backend) is reused so attrs survive the persist round-trip. + + const lastPersistedContentRef = useRef(null); const handleStateUpdate = useCallback( (state: Record) => { const code = openuiCode ?? ""; @@ -89,6 +91,10 @@ export const GenUIAssistantMessage = ({ const fullMessage = hasState ? contentPart + wrapContext(JSON.stringify([state])) : contentPart; + if (fullMessage === lastPersistedContentRef.current || fullMessage === message.content) { + return; + } + lastPersistedContentRef.current = fullMessage; updateMessage({ ...message, content: fullMessage }); }, [updateMessage, message, openuiCode, contentHeader], From 1369a0c8a391a43da868eb111a79247546aebcf0 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 20 Jul 2026 17:09:48 +0530 Subject: [PATCH 02/12] fix: format --- packages/react-headless/src/store/createChatStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index e955e5894..5820bea72 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -230,7 +230,7 @@ export const createChatStore = (config: CreateChatStoreConfig) => { if (threadId !== null) { threadStorage .updateMessage(threadId, message) - .catch((e) => set((s) => ({ threadError: e }))); + .catch((e) => set(() => ({ threadError: e }))); } }, From a8cde47b50d2bf7a07fbbdaa72a3ae8918b508d3 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 21 Jul 2026 01:53:00 +0530 Subject: [PATCH 03/12] docs: document ThreadStorage.updateMessage Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014dhkQ1Qd2zcgcbvH57eD1K --- docs/content/docs/agent/guides/migrating.mdx | 2 +- docs/content/docs/agent/reference/adapters-and-formats.mdx | 6 +++++- docs/content/docs/agent/reference/self-hosting.mdx | 4 +++- docs/public/AGENTS.md | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/agent/guides/migrating.mdx b/docs/content/docs/agent/guides/migrating.mdx index 4c8f7f59e..786ab5b72 100644 --- a/docs/content/docs/agent/guides/migrating.mdx +++ b/docs/content/docs/agent/guides/migrating.mdx @@ -116,7 +116,7 @@ The behavior change to internalize: **you no longer create or own an `AbortContr ## Thread props → `storage` -`threadApiUrl` plus the per-operation callbacks become a single `ChatStorage` whose `thread` member holds five methods. +`threadApiUrl` plus the per-operation callbacks become a single `ChatStorage` whose `thread` member holds the five required methods. ### The REST case → `restStorage` diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx index a1e47f4cb..a147203bd 100644 --- a/docs/content/docs/agent/reference/adapters-and-formats.mdx +++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx @@ -147,7 +147,7 @@ Omit `storage` entirely and `AgentInterface` uses an internal in-memory store ### `ThreadStorage` -The five methods that back thread management. Implement these and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend. +Five required methods that back thread management, plus one optional method for message write-back. Implement the required five and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend. ```ts interface ThreadStorage { @@ -156,6 +156,7 @@ interface ThreadStorage { getMessages(threadId: string): Promise; updateThread(thread: Thread): Promise; deleteThread(id: string): Promise; + updateMessage?(threadId: string, message: Message): Promise; } ``` @@ -166,6 +167,9 @@ interface ThreadStorage { | `getMessages(threadId)` | `Message[]` | The user opens a thread. | | `updateThread(thread)` | the updated `Thread` | A thread changes (e.g. a rename). | | `deleteThread(id)` | `void` | The user deletes a thread. | +| `updateMessage?(threadId, message)` | `void` | *Optional.* A rendered message is rewritten client-side — today that's form state being folded into the message content. Called fire-and-forget after the in-memory update; failures are logged, never surfaced as a thread error. | + +Omit `updateMessage` and message edits stay in-memory only — form values are lost when the thread is re-fetched (reload, or switching threads and back). Implementations receive the whole latest message; built-in inputs commit on blur/click, so calls arrive at user-interaction rate, not per keystroke. Implement these directly when your storage doesn't fit the REST shape `restStorage` expects — a different route layout, GraphQL, or a client-side store like IndexedDB: diff --git a/docs/content/docs/agent/reference/self-hosting.mdx b/docs/content/docs/agent/reference/self-hosting.mdx index 469143c10..0315a26ab 100644 --- a/docs/content/docs/agent/reference/self-hosting.mdx +++ b/docs/content/docs/agent/reference/self-hosting.mdx @@ -267,7 +267,7 @@ export async function DELETE(_req: NextRequest, { params }: { params: { threadId ### Custom `ChatStorage` instead -If the REST endpoint shape doesn't fit your backend — GraphQL, a client-side store like IndexedDB, a SaaS SDK, or just a different URL layout — implement `ChatStorage` directly. It's an object with a `thread` member satisfying `ThreadStorage` (five methods) plus an optional `artifact` member. `restStorage` is itself just a `ChatStorage` built this way for the common REST case. +If the REST endpoint shape doesn't fit your backend — GraphQL, a client-side store like IndexedDB, a SaaS SDK, or just a different URL layout — implement `ChatStorage` directly. It's an object with a `thread` member satisfying `ThreadStorage` (five required methods plus an optional `updateMessage`) plus an optional `artifact` member. `restStorage` is itself just a `ChatStorage` built this way for the common REST case. ```ts import type { ChatStorage } from "@openuidev/react-ui"; @@ -308,6 +308,8 @@ The five methods map one-to-one onto the sidebar: | `updateThread(thread)` | A thread changes (e.g. rename). Returns the updated `Thread`. | | `deleteThread(id)` | User deletes a thread. | +A sixth, optional method — `updateMessage(threadId, message)` — persists client-side message rewrites (form state folded into message content). Omit it and form values survive only in memory, disappearing when the thread is re-fetched. + ## 4. Store artifacts Adding an optional `artifact` channel to your storage makes every dashboard, report, and presentation the agent produces a durable, searchable, cross-thread record — and `` renders the entire artifact browser (sidebar entry → searchable list → full-page view) for you. `ArtifactStorage` is three methods: diff --git a/docs/public/AGENTS.md b/docs/public/AGENTS.md index 0ac348586..98feabee8 100644 --- a/docs/public/AGENTS.md +++ b/docs/public/AGENTS.md @@ -282,6 +282,7 @@ interface ThreadStorage { getMessages(threadId: string): Promise; updateThread(thread: Thread): Promise; deleteThread(id: string): Promise; + updateMessage?(threadId: string, message: Message): Promise; // optional: persists client-side message rewrites (form state); omit → edits are in-memory only } interface ArtifactStorage { From d0c93c37c568217bf1e3aebce1b3a3efa18ad48e Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 21 Jul 2026 02:11:08 +0530 Subject: [PATCH 04/12] chore: version bump --- packages/react-headless/package.json | 2 +- packages/react-ui/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-headless/package.json b/packages/react-headless/package.json index 78a79a278..c03d70c1d 100644 --- a/packages/react-headless/package.json +++ b/packages/react-headless/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/react-headless", - "version": "0.9.2", + "version": "0.9.3", "description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI and AG-UI, message format converters, and thread management for OpenUI generative UI apps", "license": "MIT", "type": "module", diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 6e77a454f..5435dcadb 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@openuidev/react-ui", "license": "MIT", - "version": "0.12.1", + "version": "0.12.2", "description": "Component library for Generative UI SDK", "main": "dist/index.cjs", "module": "dist/index.mjs", From 72d38d49debf33bcc84576d0f1397195c6587ab1 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 21 Jul 2026 02:16:56 +0530 Subject: [PATCH 05/12] fix: update docs --- docs/content/docs/agent/reference/adapters-and-formats.mdx | 4 +--- docs/content/docs/agent/reference/self-hosting.mdx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx index a147203bd..ff4b5720f 100644 --- a/docs/content/docs/agent/reference/adapters-and-formats.mdx +++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx @@ -167,9 +167,7 @@ interface ThreadStorage { | `getMessages(threadId)` | `Message[]` | The user opens a thread. | | `updateThread(thread)` | the updated `Thread` | A thread changes (e.g. a rename). | | `deleteThread(id)` | `void` | The user deletes a thread. | -| `updateMessage?(threadId, message)` | `void` | *Optional.* A rendered message is rewritten client-side — today that's form state being folded into the message content. Called fire-and-forget after the in-memory update; failures are logged, never surfaced as a thread error. | - -Omit `updateMessage` and message edits stay in-memory only — form values are lost when the thread is re-fetched (reload, or switching threads and back). Implementations receive the whole latest message; built-in inputs commit on blur/click, so calls arrive at user-interaction rate, not per keystroke. +| `updateMessage?(threadId, message)` | `void` | *Optional.* The user edits form state in a rendered message; called fire-and-forget. | Implement these directly when your storage doesn't fit the REST shape `restStorage` expects — a different route layout, GraphQL, or a client-side store like IndexedDB: diff --git a/docs/content/docs/agent/reference/self-hosting.mdx b/docs/content/docs/agent/reference/self-hosting.mdx index 0315a26ab..26d58934d 100644 --- a/docs/content/docs/agent/reference/self-hosting.mdx +++ b/docs/content/docs/agent/reference/self-hosting.mdx @@ -308,7 +308,7 @@ The five methods map one-to-one onto the sidebar: | `updateThread(thread)` | A thread changes (e.g. rename). Returns the updated `Thread`. | | `deleteThread(id)` | User deletes a thread. | -A sixth, optional method — `updateMessage(threadId, message)` — persists client-side message rewrites (form state folded into message content). Omit it and form values survive only in memory, disappearing when the thread is re-fetched. +A sixth, optional method — `updateMessage(threadId, message)` — persists client-side message rewrites (form state folded into message content). ## 4. Store artifacts From 2150702612223fd8f4e7d0592a6502f46cf275ec Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 21 Jul 2026 02:28:03 +0530 Subject: [PATCH 06/12] fix: make fn optional --- packages/react-headless/src/adapters/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-headless/src/adapters/types.ts b/packages/react-headless/src/adapters/types.ts index d269efc0a..7563033dc 100644 --- a/packages/react-headless/src/adapters/types.ts +++ b/packages/react-headless/src/adapters/types.ts @@ -11,7 +11,7 @@ export interface ThreadStorage { getMessages(threadId: string): Promise; updateThread(thread: Thread): Promise; deleteThread(id: string): Promise; - updateMessage(threadId: string, message: Message): Promise; + updateMessage?(threadId: string, message: Message): Promise; } // ── Artifact storage (global, cross-thread) ── From d854f76b1cfd5fa1ad66d86c14c3c3baf33a2b98 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 22 Jul 2026 15:29:28 +0530 Subject: [PATCH 07/12] fix: docs --- docs/content/docs/agent/guides/migrating.mdx | 2 +- docs/content/docs/agent/reference/adapters-and-formats.mdx | 2 +- docs/content/docs/agent/reference/self-hosting.mdx | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/agent/guides/migrating.mdx b/docs/content/docs/agent/guides/migrating.mdx index 786ab5b72..91b5d7a44 100644 --- a/docs/content/docs/agent/guides/migrating.mdx +++ b/docs/content/docs/agent/guides/migrating.mdx @@ -116,7 +116,7 @@ The behavior change to internalize: **you no longer create or own an `AbortContr ## Thread props → `storage` -`threadApiUrl` plus the per-operation callbacks become a single `ChatStorage` whose `thread` member holds the five required methods. +`threadApiUrl` plus the per-operation callbacks become a single `ChatStorage` whose `thread` member implements the interface. ### The REST case → `restStorage` diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx index ff4b5720f..8fb94275a 100644 --- a/docs/content/docs/agent/reference/adapters-and-formats.mdx +++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx @@ -147,7 +147,7 @@ Omit `storage` entirely and `AgentInterface` uses an internal in-memory store ### `ThreadStorage` -Five required methods that back thread management, plus one optional method for message write-back. Implement the required five and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend. +Implements methods that back thread and message management. Implement the required five and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend. ```ts interface ThreadStorage { diff --git a/docs/content/docs/agent/reference/self-hosting.mdx b/docs/content/docs/agent/reference/self-hosting.mdx index 26d58934d..312f714f9 100644 --- a/docs/content/docs/agent/reference/self-hosting.mdx +++ b/docs/content/docs/agent/reference/self-hosting.mdx @@ -267,7 +267,7 @@ export async function DELETE(_req: NextRequest, { params }: { params: { threadId ### Custom `ChatStorage` instead -If the REST endpoint shape doesn't fit your backend — GraphQL, a client-side store like IndexedDB, a SaaS SDK, or just a different URL layout — implement `ChatStorage` directly. It's an object with a `thread` member satisfying `ThreadStorage` (five required methods plus an optional `updateMessage`) plus an optional `artifact` member. `restStorage` is itself just a `ChatStorage` built this way for the common REST case. +If the REST endpoint shape doesn't fit your backend — GraphQL, a client-side store like IndexedDB, a SaaS SDK, or just a different URL layout — implement `ChatStorage` directly. It's an object with a `thread` member satisfying `ThreadStorage` plus an optional `artifact` member. `restStorage` is itself just a `ChatStorage` built this way for the common REST case. ```ts import type { ChatStorage } from "@openuidev/react-ui"; @@ -308,7 +308,6 @@ The five methods map one-to-one onto the sidebar: | `updateThread(thread)` | A thread changes (e.g. rename). Returns the updated `Thread`. | | `deleteThread(id)` | User deletes a thread. | -A sixth, optional method — `updateMessage(threadId, message)` — persists client-side message rewrites (form state folded into message content). ## 4. Store artifacts From 2241dc37aa2129624d2388a3e8291d9ab77585dc Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 22 Jul 2026 15:31:16 +0530 Subject: [PATCH 08/12] fix: update docs --- docs/content/docs/agent/reference/adapters-and-formats.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/agent/reference/adapters-and-formats.mdx b/docs/content/docs/agent/reference/adapters-and-formats.mdx index 8fb94275a..7357068a3 100644 --- a/docs/content/docs/agent/reference/adapters-and-formats.mdx +++ b/docs/content/docs/agent/reference/adapters-and-formats.mdx @@ -147,7 +147,7 @@ Omit `storage` entirely and `AgentInterface` uses an internal in-memory store ### `ThreadStorage` -Implements methods that back thread and message management. Implement the required five and the default sidebar's thread list, "New chat" button, thread switching, and deletion all operate against your backend. +Implements methods that back thread and message management. Implement the method and the default sidebar's thread list, "New chat" button, thread switching, deletion and message interactions all operate against your backend. ```ts interface ThreadStorage { From 9159e7719ce35bf76d2dad9d77aed14dfc1be8ea Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 22 Jul 2026 17:33:42 +0530 Subject: [PATCH 09/12] fix: add replaceId to use server generated id --- .../src/store/createChatStore.ts | 6 +++++- .../src/stream/processStreamedMessage.ts | 20 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index 5820bea72..68173f0ba 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -184,6 +184,10 @@ export const createChatStore = (config: CreateChatStoreConfig) => { set((s) => ({ messages: s.messages.map((m) => (m.id === msg.id ? msg : m)), })), + replaceMessageId: (previousId, serverId) => + set((s) => ({ + messages: s.messages.map((m) => (m.id === previousId ? { ...m, id: serverId } : m)), + })), // A tool's args have closed (TOOL_CALL_END) → it is now executing. markToolExecuting: (id) => set((s) => @@ -229,7 +233,7 @@ export const createChatStore = (config: CreateChatStoreConfig) => { const threadId = get().selectedThreadId; if (threadId !== null) { threadStorage - .updateMessage(threadId, message) + .updateMessage?.(threadId, message) .catch((e) => set(() => ({ threadError: e }))); } }, diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index 0be567fa0..9f3ad44f0 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -10,6 +10,8 @@ interface Parameters { createMessage: (message: Message) => void; /** A function that updates an existing message in the thread (matched by id). */ updateMessage: (message: Message) => void; + /** Relabels an existing message in place (same position, new id) */ + replaceMessageId?: (previousId: string, serverId: string) => void; /** * Marks a tool call as executing (args closed, awaiting result). Wired to the * store's `executingToolCallIds` set so `pairToolActivity` can report the @@ -29,6 +31,7 @@ export const processStreamedMessage = async ({ response, createMessage, updateMessage, + replaceMessageId, markToolExecuting = () => {}, clearToolExecuting = () => {}, adapter = agUIAdapter(), @@ -154,11 +157,18 @@ export const processStreamedMessage = async ({ } case EventType.TEXT_MESSAGE_START: - // The optimistic id is kept regardless of `event.messageId` — swapping - // ids mid-stream by deleting + re-creating the assistant message - // breaks ordering when tool messages have already been appended - // between the original create and this event (e.g. from - // TOOL_CALL_RESULT). Persistence layers should map ids on save. + // Adopt the server's message id so the message is addressable on the + // backend after the run (e.g. ThreadStorage.updateMessage). The swap is + // done IN PLACE via replaceMessageId — deleting + re-creating the + // assistant message would break ordering when tool messages were + // already appended between the original create and this event (e.g. + // from TOOL_CALL_RESULT); relabeling keeps the position. If the + // message isn't in the store yet (this is the first event), the + // trailing isFirst createMessage below already carries the server id. + if (event.messageId && event.messageId !== currentMessage.id) { + replaceMessageId?.(currentMessage.id, event.messageId); + currentMessage = { ...currentMessage, id: event.messageId }; + } break; case EventType.TOOL_CALL_RESULT: { From c499e6a7a7fb5798731f541dad7ff4dbaa5d3027 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 22 Jul 2026 17:40:58 +0530 Subject: [PATCH 10/12] fix: persist whether serverId was adopted across chunks --- .../src/stream/processStreamedMessage.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index 9f3ad44f0..9524ee737 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -45,6 +45,9 @@ export const processStreamedMessage = async ({ let isFirst = true; + // First TEXT_MESSAGE_START wins — see that case below. + let serverIdAdopted = false; + // Tool messages by toolCallId, so repeated TOOL_CALL_RESULTs for the same // call UPDATE one message in place instead of duplicating it. const toolMessagesByCallId = new Map(); @@ -165,9 +168,17 @@ export const processStreamedMessage = async ({ // from TOOL_CALL_RESULT); relabeling keeps the position. If the // message isn't in the store yet (this is the first event), the // trailing isFirst createMessage below already carries the server id. - if (event.messageId && event.messageId !== currentMessage.id) { - replaceMessageId?.(currentMessage.id, event.messageId); - currentMessage = { ...currentMessage, id: event.messageId }; + // + // First TEXT_MESSAGE_START wins: a multi-text-item run emits one per + // item, and re-relabeling would remount the message component (keyed + // by id) for each. Anchoring on the first item also matches the + // reload path, which splits a persisted turn on its first text item. + if (!serverIdAdopted && event.messageId) { + serverIdAdopted = true; + if (event.messageId !== currentMessage.id) { + replaceMessageId?.(currentMessage.id, event.messageId); + currentMessage = { ...currentMessage, id: event.messageId }; + } } break; From 10c9a0f4f1956df6a930bdd90040dd8bbd12135d Mon Sep 17 00:00:00 2001 From: AB <55152006+AbhinRustagi@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:18:14 +0530 Subject: [PATCH 11/12] update comments --- .../src/stream/processStreamedMessage.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index 9524ee737..d5a12b1d3 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -160,19 +160,16 @@ export const processStreamedMessage = async ({ } case EventType.TEXT_MESSAGE_START: - // Adopt the server's message id so the message is addressable on the - // backend after the run (e.g. ThreadStorage.updateMessage). The swap is - // done IN PLACE via replaceMessageId — deleting + re-creating the - // assistant message would break ordering when tool messages were - // already appended between the original create and this event (e.g. - // from TOOL_CALL_RESULT); relabeling keeps the position. If the - // message isn't in the store yet (this is the first event), the + // Use the server id when available (usually available from + // the TEXT_MESSAGE_START event. The swap is done IN PLACE + // — deleting + re-creating the assistant message would break + // ordering when tool messages were already appended between the + // If the message isn't in the store yet (this is the first event), the // trailing isFirst createMessage below already carries the server id. // // First TEXT_MESSAGE_START wins: a multi-text-item run emits one per // item, and re-relabeling would remount the message component (keyed - // by id) for each. Anchoring on the first item also matches the - // reload path, which splits a persisted turn on its first text item. + // by id) for each. if (!serverIdAdopted && event.messageId) { serverIdAdopted = true; if (event.messageId !== currentMessage.id) { From e05dfc60e3a309ed1de9d2121e4affcfd964e42a Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Wed, 22 Jul 2026 22:29:45 +0530 Subject: [PATCH 12/12] fix: format --- .../react-headless/src/stream/processStreamedMessage.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index d5a12b1d3..723a2c5ea 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -160,10 +160,10 @@ export const processStreamedMessage = async ({ } case EventType.TEXT_MESSAGE_START: - // Use the server id when available (usually available from + // Use the server id when available (usually available from // the TEXT_MESSAGE_START event. The swap is done IN PLACE - // — deleting + re-creating the assistant message would break - // ordering when tool messages were already appended between the + // — deleting + re-creating the assistant message would break + // ordering when tool messages were already appended between the // If the message isn't in the store yet (this is the first event), the // trailing isFirst createMessage below already carries the server id. //