Skip to content

fix(ai-components): align agent conversations with the other SDKs - #3974

Merged
isekovanic merged 16 commits into
v9-releasefrom
fix/polish-ai-components
Nov 11, 2026
Merged

isekovanic merged 16 commits into
v9-releasefrom
fix/polish-ai-components

Conversation

@isekovanic

Copy link
Copy Markdown
Contributor

Ticket: https://linear.app/stream/issue/RN-485

🎯 Goal

Fixes from comparing the AI components with React (release-v14), iOS and Android. Only what every other SDK already does, or what's a bug, not parity for its own sake. One commit per change.

🛠 Implementation details

  • Older messages: AIConversationController.loadOlderMessages() swallowed errors, so a failing load was retried every 300 ms. It now rejects, and the list waits 3 s.
  • Conversation: a pending question no longer shows twice when its copy arrives in the same update, and a turn keeps its key when older messages load above it.
  • Typewriter: keeps up with the agent (each new part within ~0.4 s) unless a fixed pace is passed. Adds skipAnimation.
  • Client tools: the runner skips calls without their own id, and maxAttempts below 1 throws.
  • Generated UI:
    • capabilities().tools are MCP-shaped and left out when there are none;
    • custom messages get showsUI / uiFailed, and the host has hasFailed().
  • Approvals: the card shows what the call sends ("What it sends"), with argumentsPreview on the approver and the card.
  • Reply actions: no copy or share under a reply without text.
  • Sign-in: "Connecting..." goes back to Connect after 30 s.
  • Language:
    • dictation uses the device's language;
    • the placeholder, voice labels and agent error text are translated, with 9 new keys in all locales.
  • A2UI:
    • also accepts the web SDK's stream-app-catalog id;
    • the instructions mark read-only tools;
    • formatting follows the device's locale, with per-currency decimals, epoch timestamps and HH:mm.
  • Android code:
    • the font name Monospace → monospace, so code is actually monospace;
    • the fast highlighter now measures in the font it draws with, and the 1.6 width padding is gone. This is native, so it needs a rebuild.

Kept as is on purpose: closing the sign-in browser still declines the call, since the router's sign-in page opens only once.

🎨 UI Changes

  • Approval cards show the call's arguments.
  • Android code is monospace, and code blocks are as wide as their longest line.

🧪 Testing

  • Unit tests for each change. yarn lint, yarn typecheck, yarn test:unit and yarn build pass.
  • On an iOS simulator and an Android emulator:
    • ExpoChatGPTSample against the agents router: A2UI replies, streaming, placeholder;
    • ExpoCrewAssistant on the hosted router: approval arguments, decline;
    • fixed narrow and wide code blocks on Android, before and after the measurement fix.

☑️ Checklist

  • I have signed the Stream CLA (required)
  • PR targets the develop branch (targets v9-release, where the agent components live)
  • Documentation is updated
  • New code is tested in main example apps, including all possible scenarios
    • SampleApp iOS and Android
    • Expo iOS and Android

…y fail

AIConversationController.loadOlderMessages swallowed the transport's error, so the
conversation's list took the load for a success and asked again every 300 ms while the person
stayed at the top. It now rejects, and the list waits 3 s before it asks again.
A pending question whose server copy arrived in the same update it was first seen in showed
twice: the copy counted as a message the question couldn't be. It's now matched against the
previous update's messages. A page of older messages that started with a reply took that reply
into the turn above it, which changed the turn's key and re-pinned the latest one. A row that
started a turn now keeps starting one.
StreamingMessageView typed a fixed 2 letters a frame, so a fast model left it behind, and the
reply's actions, which wait for it, came late. Without letterInterval or renderingLetterCount
it now types each new part within about 0.4 seconds, at least a letter a frame, as the web SDK
does. Passing either keeps a fixed pace. The view and useStreamingMessage also offer
skipAnimation, to show the rest at once.
A step without an id is told apart by its place in the reply, such as ai_tool_call-0, which
another reply repeats, so a later call looked already sent and never ran. The runner now skips
such calls, as Android's does. A maxAttempts below 1, which silently never ran anything, now
throws.
AIUIHost.capabilities() sent the tools' definitions as given, and an empty list without tools.
It now sends them as the web SDK does, as Model Context Protocol tools with an inputSchema ({
type: 'object' } when there is none) and a boolean readOnlyHint, and leaves tools out when
there are none. aiUIClientToolJSON is exported for backends that write the capabilities
themselves.
…or failed to

A custom AssistantMessage couldn't tell a reply that answers with UI, or whose UI failed to
show, from one that answered with nothing. It now gets showsUI and uiFailed, as on the web, and
AIUIHost has hasFailed(messageId).
…s it

The approval card asked only the agent's question, so the user allowed a call without seeing
what it would send. As on the web, it now shows the call's arguments as indented JSON under
"What it sends", folded when long. The approver's argumentsPreview(call) gives a view of its
own, or null for none, AIToolApprovalCard takes the preview as argumentsPreview, and
AIToolApproval's render function gets it as its 4th argument. AIToolArguments is the default
view.
A reply that answers only with UI or attachments showed copy and share, and sharing it shared
an empty string. Both now show only when the reply has text, as on iOS, and the actions render
nothing when there's nothing to offer.
Once a sign-in came back connected, the card said "Connecting..." until the agent changed the
call's step, which it may never do. After 30 seconds it now offers Connect again, as Android
does.
Dictation always recognized en-US; it now uses the device's language, as every other SDK does,
unless a language is given. The composer's placeholder, the voice visualizations' name and
state labels, and the default text of AIAgentError were English only; they now go through the
Chat SDK's translations, with keys in every locale. AI_AGENT_ERROR_MESSAGE is the error's key,
so the samples show t(error.message).
The web SDK names the app's catalog stream-app-catalog, so an agent set up from a web app's
capabilities wrote surfaces this renderer refused. With the default catalog id,
getstream.io:app-catalog, which iOS and Android also use, the renderer now accepts that id too,
and so does a2uiProblems. It's exported as A2UI_WEB_CATALOG_ID.
The A2UI instructions gave the app's tools only as functions of the catalog, so the model could
bind a tool that changes data to a display, which the host then refuses to run. They now list
the tools, mark the read-only ones, and say that the others run only from a button the user
taps, as Android's do.
A2UI's formatting always used en-US, formatCurrency always used 2 decimals, and formatDate
returned nothing for epoch timestamps and times of day. Numbers and dates now follow the
device's locale unless the renderer is given one, each currency gets its own decimals unless
decimals is given, and formatDate reads epoch seconds and milliseconds and HH:mm, as Android's
does.
Inline code in messages, Markdown code in the AI components and the syntax highlighter's
default asked Android for the font 'Monospace'. Android knows it only as 'monospace', and falls
back to the default font without a warning, so code was drawn in a proportional font. iOS is
unchanged.
…rawn in

The fast syntax highlighter's native text measured its width without its fontFamily, in
Android's default font, then drew in monospace, which is wider. The width was padded by a flat
1.6 to make up for it, which left empty space after most code and still clipped and wrapped
lines of narrow characters, such as i and l. The measurement now uses the font the text is
drawn in, and the padding is gone, so a block is as wide as its longest line.
… every message

Matching a pending question's copy that arrives in the same update the question is first seen
in kept the ids of all the person's messages from every update, so every streamed chunk of a
reply went over the whole list. The copy of a question just sent is always the person's newest
message, so the tracker now remembers only that one, found by stepping back from the end, and
takes it for the copy when it arrived with the update.
@Stream-SDK-Bot

Copy link
Copy Markdown
Contributor

SDK Size

title develop branch diff status
js_bundle_size 2027 KB 2047 KB +20451 B 🔴
ai_components_size 163 KB 342 KB +183236 B 🔴
a2ui_size n/a 103 KB n/a 🆕

@isekovanic
isekovanic merged commit 6f82d62 into v9-release Nov 11, 2026
7 checks passed
@isekovanic
isekovanic deleted the fix/polish-ai-components branch November 11, 2026 15:00
oliverlaz added a commit to GetStream/stream-chat-react that referenced this pull request Nov 11, 2026
…ns (#3428)

Ticket:
https://linear.app/stream/issue/REACT-1559/ai-components-add-aiconversationcontroller-port-from-react-native-and

### Goal

Every app built on the AI components works out `messages`,
`pendingMessage`, `isThinking`, ratings, retries and editing itself.
StreamChatAI (iOS) and stream-chat-react-native already share one
conversation-state contract (`AIConversation` /
`AIConversationController`). This brings the same controller to the web
kit, so both JS kits read the same and an agent SDK transport
(`agentChatTransport`) can plug into either.

### Implementation details

- `Conversation/controller/`: `AIConversationController`,
`AIConversationExchange`, `AIAgentError` + `AI_AGENT_ERROR_MESSAGE`,
`offers`, `types`, `useAIConversationState`, copied from React Native
`v9-release` (including GetStream/stream-chat-react-native#3974). Names
and behaviour are kept: send, stop, regenerate, edit/cancelEditing,
retry, clearError, rate, loadOlderMessages, `actions`, `chatOption`,
`custom`, the reply timeout and React Native's retry. A generated UI's
action travels as `request.custom.uiAction`. Web adaptations only:
- request attachments are `File`s; a pending message shows them through
object URLs the controller makes once per file and revokes on
`dispose()`;
- the composer store is the web `AIMessageComposerStore`, which gains
`isEditing`, `chatOption` and `custom` (new
`createAIMessageComposerStore()`, React Native's
`createNewComposerStore`);
- ids come from `crypto.randomUUID` (with a fallback for insecure
contexts), deep comparison uses `react-fast-compare`, and
`useStateStore` is the SDK's own;
  - `AIMessage` gains `rating`;
- `AI_AGENT_ERROR_MESSAGE` stays English text, as in React Native; web
translation keys are dotted ids, so the README shows how to detect the
error and show copy of your own.
- `<AIConversation conversation={…}>`: reads messages, the pending
message, thinking (status `submitted`), older messages, the approver and
reply actions (rating, regenerate when offered), and offers edit on the
person's latest message. The existing props keep working; `messages` is
now optional.
- `<AIMessageComposer conversation={…}>`: uses the controller's composer
store; submitting sends `{ text, attachments, chatOption, custom }`
through `conversation.send` unless `onSubmit` calls `preventDefault()`;
`SubmitButton` turns into a `StopButton` while the agent answers, and
Enter doesn't send meanwhile; an "Editing" chip with a cancel button
shows while `isEditing`, and the text input takes focus. `ChatOptions`
keeps the picked option in the store, where it stays picked across a
form reset.
- `AIPendingMessageTracker` / `turnsOf`: ported the fixes from
GetStream/stream-chat-react-native#3974. A question no longer shows
twice when its copy arrives in the same update, and a turn keeps its key
when older messages load above it. The new tests fail on master.
- Two new translation keys: `aiComponents.messageComposer.editing.text`
and `aiComponents.messageComposer.cancelEditing.ariaLabel`.
- README: a new `AIConversationController` section, including the
StrictMode-safe pattern (create the controller in a `useEffect` and
`dispose()` it in that effect's cleanup, never `useState(() => new …)`
plus dispose).

Tests: React Native's controller and exchange suites ported to Vitest
(100 tests), UI tests for the controlled conversation, the composer and
a StrictMode mount, and the tracker regressions. `yarn test`, `yarn
lint`, `yarn types` and `yarn validate-translations` pass; `yarn
types:tests` reports no errors in the touched files.

### UI Changes

- Composer: an "Editing" chip with a cancel button above the text while
a message is edited, styled like the chat-option chip; in
`layout='inline'` it takes a full-width row. Not yet checked in a
browser.
- Composer with a conversation: the send button turns into the stop
button while the agent answers.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Added controller-backed AI conversations with response status, message
editing, retry and regeneration, stopping responses, reply ratings, and
loading older messages.
* AI conversation and composer components can connect to a conversation
controller. The composer supports editing drafts, canceling edits, and
showing a stop control while a response is in progress.
* Added conversation state subscriptions and configurable chat-option
selection.
  * Added translated labels for canceling edits and the editing status.
* **Documentation**
* Updated AI component guidance with controller setup, lifecycle, and
usage details.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
oliverlaz added a commit to GetStream/stream-chat-react that referenced this pull request Nov 11, 2026
…ns (v14) (#3429)

Ticket:
https://linear.app/stream/issue/REACT-1559/ai-components-add-aiconversationcontroller-port-from-react-native-and

### Goal

The v14 port of #3428, for the `release-v14` line.

Every app built on the AI components works out `messages`,
`pendingMessage`, `isThinking`, ratings, retries and editing itself.
StreamChatAI (iOS) and stream-chat-react-native already share one
conversation-state contract (`AIConversation` /
`AIConversationController`). This brings the same controller to the web
kit, so both JS kits read the same and an agent SDK transport
(`agentChatTransport`) can plug into either.

### Implementation details

- `Conversation/controller/`: `AIConversationController`,
`AIConversationExchange`, `AIAgentError` + `AI_AGENT_ERROR_MESSAGE`,
`offers`, `types`, `useAIConversationState`, copied from React Native
`v9-release` (including GetStream/stream-chat-react-native#3974). Names
and behaviour are kept: send, stop, regenerate, edit/cancelEditing,
retry, clearError, rate, loadOlderMessages, `actions`, `chatOption`,
`custom`, the reply timeout and React Native's retry. A generated UI's
action travels as `request.custom.uiAction`. Web adaptations only:
- request attachments are `File`s; a pending message shows them through
object URLs the controller makes once per file and revokes on
`dispose()`;
- the composer store is the web `AIMessageComposerStore`, which gains
`isEditing`, `chatOption` and `custom` (new
`createAIMessageComposerStore()`, React Native's
`createNewComposerStore`);
- ids come from `crypto.randomUUID` (with a fallback for insecure
contexts), deep comparison uses `react-fast-compare`, `StateStore` comes
from `stream-chat`, and `useStateStore` is the SDK's own;
  - `AIMessage` gains `rating`;
- `AI_AGENT_ERROR_MESSAGE` stays English text, as in React Native; the
README shows how to detect the error and show copy of your own.
- `<AIConversation conversation={…}>`: reads messages, the pending
message, thinking (status `submitted`), older messages, the approver and
reply actions (rating, regenerate when offered), and offers edit on the
person's latest message. The existing props keep working; `messages` is
now optional.
- `<AIMessageComposer conversation={…}>`: uses the controller's composer
store; submitting sends `{ text, attachments, chatOption, custom }`
through `conversation.send` unless `onSubmit` calls `preventDefault()`;
`SubmitButton` turns into a `StopButton` while the agent answers, and
Enter doesn't send meanwhile; an "Editing" chip with a cancel button
shows while `isEditing`, and the text input takes focus. `ChatOptions`
keeps the picked option in the store, where it stays picked across a
form reset.
- `AIPendingMessageTracker` / `turnsOf`: ported the fixes from
GetStream/stream-chat-react-native#3974. A question no longer shows
twice when its copy arrives in the same update, and a turn keeps its key
when older messages load above it. The new tests fail on `release-v14`.
- Two new translation keys, `Editing` and `aria/Cancel editing`, filled
in all 12 locales (React Native's wording where it has them; German and
Portuguese are new).
- README: a new `AIConversationController` section, including the
StrictMode-safe pattern (create the controller in a `useEffect` and
`dispose()` it in that effect's cleanup, never `useState(() => new …)`
plus dispose).

Tests: React Native's controller and exchange suites ported to Vitest
(100 tests), UI tests for the controlled conversation, the composer and
a StrictMode mount, and the tracker regressions. `yarn lint` (including
translation validation), `yarn types` and `yarn test` (266 files, 3550
tests) pass.

### UI Changes

- Composer: an "Editing" chip with a cancel button above the text while
a message is edited, styled like the chat-option chip; in
`layout='inline'` it takes a full-width row. Not yet checked in a
browser.
- Composer with a conversation: the send button turns into the stop
button while the agent answers.
github-actions Bot pushed a commit to GetStream/stream-chat-react that referenced this pull request Nov 11, 2026
## [15.0.1](v15.0.0...v15.0.1) (2026-11-11)

### Bug Fixes

* **AIComponents:** add AIConversationController for agent conversations ([#3428](#3428)) ([b6793a2](b6793a2)), closes [GetStream/stream-chat-react-native#3974](GetStream/stream-chat-react-native#3974)
* **AIMessageComposer:** add Actions, StopButton and an inline layout ([#3424](#3424)) ([b65bfa6](b65bfa6)), closes [GetStream/getstream.io#1034](https://github.com/GetStream/getstream.io/issues/1034)
* **styling:** drop [@charset](https://github.com/charset) from the published CSS ([#3430](#3430)) ([be9a3aa](be9a3aa)), closes [#3423](#3423)
* **VirtualizedMessageList:** clear a jump's highlight once the message was viewed ([#3420](#3420)) ([d2c1796](d2c1796))
github-actions Bot pushed a commit to GetStream/stream-chat-react that referenced this pull request Nov 11, 2026
## [14.17.0](v14.16.1...v14.17.0) (2026-11-11)

### Bug Fixes

* **AIComponents:** add AIConversationController for agent conversations (v14) ([#3429](#3429)) ([1b67c7b](1b67c7b)), closes [#3428](#3428) [GetStream/stream-chat-react-native#3974](GetStream/stream-chat-react-native#3974)
* **styling:** drop [@charset](https://github.com/charset) from the published CSS (v14) ([#3431](#3431)) ([6d0cf05](6d0cf05)), closes [#3423](#3423)

### Features

* **AIMessageComposer:** add Actions, StopButton and an inline layout (v14) ([#3425](#3425)) ([cbc9f80](cbc9f80)), closes [#3424](#3424) [#3424](#3424) [#3424](#3424) [GetStream/getstream.io#1034](https://github.com/GetStream/getstream.io/issues/1034)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants