Skip to content

feat: configurable client-side message queue for useChat#900

Open
AlemTuzlak wants to merge 23 commits into
mainfrom
feat/client-message-queue
Open

feat: configurable client-side message queue for useChat#900
AlemTuzlak wants to merge 23 commits into
mainfrom
feat/client-message-queue

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

What & why

Today sendMessage silently drops any message sent while a stream is in flight. This adds a first-class, observable, configurable message queue: messages sent mid-stream are queued by default, rendered separately, auto-sent when the stream settles, and cancellable before they go out.

const { messages, queue, sendMessage, cancelQueued } = useChat({
  connection: fetchServerSentEvents('/api/chat'),
  queue: {
    whenBusy: 'queue',       // 'queue' | 'drop' | 'interrupt'
    drain: 'fifo',           // 'fifo' | 'batch'
    maxSize: 5,
    onOverflow: 'reject',    // 'reject' | 'drop-oldest'
  },
})

// shorthand: queue: 'interrupt'   |   escape hatch: queue: (ctx) => ({ action })
sendMessage('urgent', { whenBusy: 'interrupt' })       // per-send override
queue.map((q) => <Pending key={q.id} onCancel={() => cancelQueued(q.id)} />)

Behavior

  • whenBusyqueue (hold + auto-send), drop (ignore, the old behavior), interrupt (abort current stream, send now).
  • drainfifo (one at a time, in order) or batch (merge all queued into one send: strings joined by \n, multimodal content concatenated).
  • maxSize / onOverflow — cap the queue; reject the incoming send or evict the oldest.
  • Accepts a WhenBusy string shorthand or a QueueStrategy function for full control.
  • Queue flushes on stop() / clear() / unsubscribe() and on stream error (never strands or mis-orders queued sends).
  • Queued items live in their own array — never mixed into messages, never sent to the wire until drained.

⚠️ Behavior change

Sends while streaming are now queued by default (previously dropped). Opt back into the old behavior with queue: 'drop'. Flagged as a minor bump in the changeset.

Surface

  • @tanstack/ai-client — core: queue option, QueuedMessage, getQueue(), cancelQueued(), onQueueChange, FIFO/batch drain, lifecycle + error flush, devtools snapshot.
  • @tanstack/ai-react / -solid / -vue / -svelte / -preact — expose queue + cancelQueued, per-send { whenBusy } override; ai-vue-ui forwards automatically.

Tests & docs

  • Unit tests in ai-client (each whenBusy mode, FIFO order, batch merge incl. multimodal, maxSize/overflow, cancel, flush-on-stop/clear/error) + framework hook tests updated for queue-by-default.
  • E2E (testing/e2e/tests/queue.spec.ts): send → queue-while-streaming → cancel one → assert only the uncancelled message is delivered, in order.
  • Docs: new "Queueing messages" section in docs/chat/streaming.md; chat-experience agent skill updated.
  • pnpm test:pr green across all 50 projects.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added message queueing for chat sends made while a response is streaming (default behavior).
    • Expose pending queued messages and allow canceling individual queued items; supports configurable queue policies and per-send whenBusy overrides.
    • Added queue visibility in devtools snapshots.
  • Bug Fixes
    • Prevented concurrent sends from being silently dropped; queued sends now drain automatically after the active stream settles.
  • Documentation
    • Updated streaming guidance with a new “Queueing Messages” section and examples.
  • Tests
    • Updated unit and hook tests for queued delivery, plus added an end-to-end queueing spec.

AlemTuzlak added 20 commits July 6, 2026 14:51
…unsubscribe

Wires the queue drain into streamResponse's settle path so queued sends
actually go out (fifo one-at-a-time, or batch merged with newlines), and
flushes any pending queue on stop()/clear()/unsubscribe() so callers don't
get a surprise send after tearing down. Also surfaces the queue on the
devtools snapshot.
A RUN_ERROR or thrown non-abort error left streamCompletedSuccessfully
false, so the finally block's queue handling (guarded by that flag) was
skipped entirely. Queued messages were stranded until a later direct
sendMessage sent first and drained the stale queued item afterward,
inverting message order. Flush (not drain) the queue on the non-success
settle path, matching stop()'s existing behavior.
Mirrors the ai-react implementation (17bb417): ChatClient's queue-by-default
behavior is now wired through the Preact useChat hook via onQueueChange,
with cancelQueued and per-send whenBusy passthrough. Updates the two tests
that previously asserted a mid-stream second sendMessage was dropped to
instead assert both messages land in order.
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🚀 Changeset Version Preview

9 package(s) bumped directly, 36 bumped as dependents.

🟥 Major bumps

Package Version Reason
@tanstack/ai-anthropic 0.16.0 → 1.0.0 Changeset
@tanstack/ai-openai 0.15.10 → 1.0.0 Changeset
@tanstack/ai-preact 0.10.2 → 1.0.0 Changeset
@tanstack/ai-react 0.16.3 → 1.0.0 Changeset
@tanstack/ai-solid 0.14.2 → 1.0.0 Changeset
@tanstack/ai-svelte 0.14.2 → 1.0.0 Changeset
@tanstack/ai-vue 0.14.2 → 1.0.0 Changeset
@tanstack/ai-acp 0.2.0 → 1.0.0 Dependent
@tanstack/ai-angular 0.2.2 → 1.0.0 Dependent
@tanstack/ai-bedrock 0.1.1 → 1.0.0 Dependent
@tanstack/ai-claude-code 0.2.0 → 1.0.0 Dependent
@tanstack/ai-code-mode 0.3.5 → 1.0.0 Dependent
@tanstack/ai-code-mode-skills 0.3.8 → 1.0.0 Dependent
@tanstack/ai-codex 0.2.0 → 1.0.0 Dependent
@tanstack/ai-elevenlabs 0.2.31 → 1.0.0 Dependent
@tanstack/ai-fal 0.9.9 → 1.0.0 Dependent
@tanstack/ai-gemini 0.19.0 → 1.0.0 Dependent
@tanstack/ai-grok 0.14.6 → 1.0.0 Dependent
@tanstack/ai-grok-build 0.2.0 → 1.0.0 Dependent
@tanstack/ai-groq 0.5.0 → 1.0.0 Dependent
@tanstack/ai-isolate-node 0.1.44 → 1.0.0 Dependent
@tanstack/ai-isolate-quickjs 0.1.44 → 1.0.0 Dependent
@tanstack/ai-mistral 0.2.0 → 1.0.0 Dependent
@tanstack/ai-ollama 0.8.13 → 1.0.0 Dependent
@tanstack/ai-opencode 0.2.0 → 1.0.0 Dependent
@tanstack/ai-openrouter 0.15.7 → 1.0.0 Dependent
@tanstack/ai-react-ui 0.8.12 → 1.0.0 Dependent
@tanstack/ai-sandbox 0.2.1 → 1.0.0 Dependent
@tanstack/ai-sandbox-cloudflare 0.2.1 → 1.0.0 Dependent
@tanstack/ai-sandbox-daytona 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-docker 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-local-process 0.2.0 → 1.0.0 Dependent
@tanstack/ai-sandbox-sprites 0.2.1 → 1.0.0 Dependent
@tanstack/ai-sandbox-vercel 0.2.0 → 1.0.0 Dependent
@tanstack/ai-solid-ui 0.7.11 → 1.0.0 Dependent
@tanstack/openai-base 0.9.6 → 1.0.0 Dependent

🟨 Minor bumps

Package Version Reason
@tanstack/ai 0.39.1 → 0.40.0 Changeset
@tanstack/ai-client 0.19.2 → 0.20.0 Changeset

🟩 Patch bumps

Package Version Reason
@tanstack/ai-devtools-core 0.4.21 → 0.4.22 Dependent
@tanstack/ai-isolate-cloudflare 0.2.35 → 0.2.36 Dependent
@tanstack/ai-mcp 0.2.2 → 0.2.3 Dependent
@tanstack/ai-vue-ui 0.2.30 → 0.2.31 Dependent
@tanstack/preact-ai-devtools 0.1.64 → 0.1.65 Dependent
@tanstack/react-ai-devtools 0.2.64 → 0.2.65 Dependent
@tanstack/solid-ai-devtools 0.2.64 → 0.2.65 Dependent

@nx-cloud

nx-cloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 7401f07

Command Status Duration Result
nx run-many --targets=build --exclude=examples/... ✅ Succeeded 19s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-06 15:24:02 UTC

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d2547104-425e-44bc-9e54-c8318a5a42a8

📥 Commits

Reviewing files that changed from the base of the PR and between 6aa7207 and 7401f07.

📒 Files selected for processing (8)
  • examples/ts-react-chat/src/components/Header.tsx
  • examples/ts-react-chat/src/routeTree.gen.ts
  • examples/ts-react-chat/src/routes/queueing.tsx
  • packages/ai-preact/src/index.ts
  • packages/ai-react/src/index.ts
  • packages/ai-solid/src/index.ts
  • packages/ai-svelte/src/index.ts
  • packages/ai-vue/src/index.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/ai-preact/src/index.ts
  • examples/ts-react-chat/src/routeTree.gen.ts

📝 Walkthrough

Walkthrough

This PR adds default queueing for sendMessage calls made during an active stream, with configurable busy handling, queue draining, cancellation, and queue state exposure. The queue API is threaded through ai-client and each framework adapter, with docs, examples, and e2e coverage updated accordingly.

Changes

Message Queueing Feature

Layer / File(s) Summary
Queue types and configuration contract
packages/ai-client/src/types.ts, packages/ai-client/src/index.ts, packages/ai-client/src/devtools.ts, packages/ai-preact/src/types.ts, packages/ai-react/src/types.ts, packages/ai-solid/src/types.ts, packages/ai-svelte/src/types.ts, packages/ai-vue/src/types.ts, .changeset/message-queue.md
Adds queue-related types and public re-exports, extends client options with queue/onQueueChange, and documents the new queue behavior and per-call whenBusy override.
ChatClient queue engine
packages/ai-client/src/chat-client.ts
Implements queue normalization, enqueue/drain/flush logic, busy-state send handling, queue snapshots, and queue management APIs in ChatClient.
ChatClient queue tests
packages/ai-client/tests/chat-client-queue.test.ts, packages/ai-client/tests/chat-client.test.ts
Covers queue normalization, queued streaming behavior, drain modes, cancellation, flushing, and updated loading-concurrency expectations.
Preact and React hook queue integration
packages/ai-preact/src/use-chat.ts, packages/ai-preact/tests/use-chat.test.ts, packages/ai-react/src/use-chat.ts, packages/ai-react/tests/use-chat.test.ts
Threads queue state and controls through the hooks and updates concurrency tests to expect queued ordered delivery.
Solid, Svelte, and Vue hook queue integration
packages/ai-solid/src/use-chat.ts, packages/ai-solid/tests/use-chat.test.ts, packages/ai-svelte/src/create-chat.svelte.ts, packages/ai-vue/src/use-chat.ts, packages/ai-vue/tests/use-chat.test.ts
Adds queue state, cancelQueued, and whenBusy support to the other framework adapters and their tests.
Docs, example route, and generated router tree
docs/chat/streaming.md, docs/config.json, packages/ai/skills/ai-core/chat-experience/SKILL.md, examples/ts-react-chat/src/components/Header.tsx, examples/ts-react-chat/src/routeTree.gen.ts, examples/ts-react-chat/src/routes/queueing.tsx
Documents queueing behavior, adds a queueing example page and navigation entry, and registers the new route.
E2E queue UI and spec
testing/e2e/fixtures/queue/basic.json, testing/e2e/src/components/ChatUI.tsx, testing/e2e/src/routes/$provider/$feature.tsx, testing/e2e/tests/queue.spec.ts
Adds queue-aware UI rendering and cancellation plus a Playwright test that exercises queued sends, cancellation, and drain order.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant useChat
  participant ChatClient
  participant queue

  User->>useChat: sendMessage(content, { whenBusy })
  useChat->>ChatClient: sendMessage(content, whenBusy)
  ChatClient->>ChatClient: decideWhenBusy(isStreaming)
  alt enqueue
    ChatClient->>queue: add queued message
    ChatClient->>useChat: onQueueChange(queue)
  else interrupt
    ChatClient->>ChatClient: stop in-flight stream
    ChatClient->>ChatClient: send immediately
  end
  ChatClient-->>useChat: stream settles
  ChatClient->>queue: drainQueue() or flushQueue()
  ChatClient->>useChat: onQueueChange(queue)
Loading

Suggested reviewers: tombeckenham

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description has useful details, but it does not follow the required template and omits the checklist and release-impact sections. Restructure it to match the template, add the checklist items, and include a release-impact section stating whether this is a release change or docs/CI/dev-only.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a configurable client-side message queue for useChat.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/client-message-queue

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

@tanstack/ai

npm i https://pkg.pr.new/@tanstack/ai@900

@tanstack/ai-acp

npm i https://pkg.pr.new/@tanstack/ai-acp@900

@tanstack/ai-angular

npm i https://pkg.pr.new/@tanstack/ai-angular@900

@tanstack/ai-anthropic

npm i https://pkg.pr.new/@tanstack/ai-anthropic@900

@tanstack/ai-bedrock

npm i https://pkg.pr.new/@tanstack/ai-bedrock@900

@tanstack/ai-claude-code

npm i https://pkg.pr.new/@tanstack/ai-claude-code@900

@tanstack/ai-client

npm i https://pkg.pr.new/@tanstack/ai-client@900

@tanstack/ai-code-mode

npm i https://pkg.pr.new/@tanstack/ai-code-mode@900

@tanstack/ai-code-mode-skills

npm i https://pkg.pr.new/@tanstack/ai-code-mode-skills@900

@tanstack/ai-codex

npm i https://pkg.pr.new/@tanstack/ai-codex@900

@tanstack/ai-devtools-core

npm i https://pkg.pr.new/@tanstack/ai-devtools-core@900

@tanstack/ai-elevenlabs

npm i https://pkg.pr.new/@tanstack/ai-elevenlabs@900

@tanstack/ai-event-client

npm i https://pkg.pr.new/@tanstack/ai-event-client@900

@tanstack/ai-fal

npm i https://pkg.pr.new/@tanstack/ai-fal@900

@tanstack/ai-gemini

npm i https://pkg.pr.new/@tanstack/ai-gemini@900

@tanstack/ai-grok

npm i https://pkg.pr.new/@tanstack/ai-grok@900

@tanstack/ai-grok-build

npm i https://pkg.pr.new/@tanstack/ai-grok-build@900

@tanstack/ai-groq

npm i https://pkg.pr.new/@tanstack/ai-groq@900

@tanstack/ai-isolate-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-isolate-cloudflare@900

@tanstack/ai-isolate-node

npm i https://pkg.pr.new/@tanstack/ai-isolate-node@900

@tanstack/ai-isolate-quickjs

npm i https://pkg.pr.new/@tanstack/ai-isolate-quickjs@900

@tanstack/ai-mcp

npm i https://pkg.pr.new/@tanstack/ai-mcp@900

@tanstack/ai-mistral

npm i https://pkg.pr.new/@tanstack/ai-mistral@900

@tanstack/ai-ollama

npm i https://pkg.pr.new/@tanstack/ai-ollama@900

@tanstack/ai-openai

npm i https://pkg.pr.new/@tanstack/ai-openai@900

@tanstack/ai-opencode

npm i https://pkg.pr.new/@tanstack/ai-opencode@900

@tanstack/ai-openrouter

npm i https://pkg.pr.new/@tanstack/ai-openrouter@900

@tanstack/ai-preact

npm i https://pkg.pr.new/@tanstack/ai-preact@900

@tanstack/ai-react

npm i https://pkg.pr.new/@tanstack/ai-react@900

@tanstack/ai-react-ui

npm i https://pkg.pr.new/@tanstack/ai-react-ui@900

@tanstack/ai-sandbox

npm i https://pkg.pr.new/@tanstack/ai-sandbox@900

@tanstack/ai-sandbox-cloudflare

npm i https://pkg.pr.new/@tanstack/ai-sandbox-cloudflare@900

@tanstack/ai-sandbox-daytona

npm i https://pkg.pr.new/@tanstack/ai-sandbox-daytona@900

@tanstack/ai-sandbox-docker

npm i https://pkg.pr.new/@tanstack/ai-sandbox-docker@900

@tanstack/ai-sandbox-local-process

npm i https://pkg.pr.new/@tanstack/ai-sandbox-local-process@900

@tanstack/ai-sandbox-sprites

npm i https://pkg.pr.new/@tanstack/ai-sandbox-sprites@900

@tanstack/ai-sandbox-vercel

npm i https://pkg.pr.new/@tanstack/ai-sandbox-vercel@900

@tanstack/ai-solid

npm i https://pkg.pr.new/@tanstack/ai-solid@900

@tanstack/ai-solid-ui

npm i https://pkg.pr.new/@tanstack/ai-solid-ui@900

@tanstack/ai-svelte

npm i https://pkg.pr.new/@tanstack/ai-svelte@900

@tanstack/ai-utils

npm i https://pkg.pr.new/@tanstack/ai-utils@900

@tanstack/ai-vue

npm i https://pkg.pr.new/@tanstack/ai-vue@900

@tanstack/ai-vue-ui

npm i https://pkg.pr.new/@tanstack/ai-vue-ui@900

@tanstack/openai-base

npm i https://pkg.pr.new/@tanstack/openai-base@900

@tanstack/preact-ai-devtools

npm i https://pkg.pr.new/@tanstack/preact-ai-devtools@900

@tanstack/react-ai-devtools

npm i https://pkg.pr.new/@tanstack/react-ai-devtools@900

@tanstack/solid-ai-devtools

npm i https://pkg.pr.new/@tanstack/solid-ai-devtools@900

commit: 7401f07

The framework packages re-exported QueuedMessage/WhenBusy/QueueConfig/
QueueStrategy/QueueOption from their internal types module but never
forwarded them through the public index entry, so importing them from
e.g. @tanstack/ai-react failed at the package boundary.
Side-by-side panels (queue/fifo, interrupt, queue/batch) driven by a
shared composer, showcasing how each queue strategy handles a message
sent while a stream is in flight, with live queue rendering + cancel.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/ai-solid/src/use-chat.ts (1)

141-144: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Same queue-sync gap as the React adapter.

options.queue is only captured once when the client is created; there's no createEffect re-applying it via client().updateOptions(...) the way body/forwardedProps/context are synced at Line 152-165. See the parallel comment on packages/ai-react/src/use-chat.ts (Line 162-168) — same underlying question of whether queue is meant to be static-per-session or live-updatable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-solid/src/use-chat.ts` around lines 141 - 144, The chat queue is
only read once when the client is created in useChat, so later changes to
options.queue are never synced into the live client. Add a createEffect
alongside the existing body/forwardedProps/context syncing that calls
client().updateOptions(...) whenever options.queue changes, using the same
pattern as the other reactive option updates in useChat so the queue stays in
sync if it is meant to be live-updatable.
🧹 Nitpick comments (3)
packages/ai-preact/src/use-chat.ts (1)

157-163: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

queue config isn't kept in sync after mount.

Unlike body/forwardedProps/context (synced via the updateOptions effect below), queue is only read once at client construction (useMemo keyed on [clientId]). If a consumer changes options.queue (e.g. toggling whenBusy) after mount without changing id, the change is silently ignored. This mirrors the existing behavior for other one-time options like tools/persistence, so it's consistent with current design, but worth confirming it's intentional for a config surface consumers may want to adjust dynamically (e.g., per-message whenBusy overrides already exist for that case).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-preact/src/use-chat.ts` around lines 157 - 163, The `queue`
option in `useChat` is only applied when the client is created in the `useMemo`
block, so later changes to `options.queue` are ignored after mount. Update the
`updateOptions` path in `use-chat.ts` (the effect that already keeps `body`,
`forwardedProps`, and `context` in sync) to also propagate `queue` when
`optionsRef.current.queue` changes, using the same `activeClientRef.current ===
instance` guard as `onQueueChange`. If `queue` is meant to stay one-time like
`tools`/`persistence`, make that behavior explicit in the option handling and
docs.
packages/ai-client/src/chat-client.ts (1)

803-846: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the new sendOptions parameter.

The extensive JSDoc/example block for sendMessage wasn't updated to mention the new third parameter (sendOptions.whenBusy), even though it's a public API addition central to this PR.

📝 Suggested doc addition
    * `@param` body - Optional body parameters to merge with the client's base body for this request.
    *               Uses shallow merge with per-message body taking priority.
+   * `@param` sendOptions - Optional per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
+   *                      override the configured queue policy for this one send.
    *
    * `@example`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/src/chat-client.ts` around lines 803 - 846, The public
JSDoc for sendMessage is missing the new third parameter, so update the
documentation block for sendMessage to describe sendOptions and its whenBusy
option alongside content and body. Add a brief `@param` entry and, if helpful, a
small example showing how sendOptions.whenBusy is used, so the docs match the
new signature in chat-client.ts.
packages/ai-client/tests/chat-client-queue.test.ts (1)

71-328: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for interrupt, per-call whenBusy override, and maxSize/onOverflow.

This suite covers default queueing, drop, FIFO/batch drain, and flush-on-stop/error well, but three documented behaviors have zero test coverage:

  • whenBusy: 'interrupt' (abort + send now)
  • per-call sendOptions.whenBusy override on sendMessage
  • maxSize with onOverflow: 'reject' vs 'drop-oldest'

Adding tests for the overflow behavior in particular would surface the maxSize: 0 boundary issue flagged in chat-client.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/tests/chat-client-queue.test.ts` around lines 71 - 328,
Add tests in ChatClient message queue coverage for the missing documented
behaviors: verify `whenBusy: 'interrupt'` aborts the in-flight send and allows
the new message to send immediately, verify `sendMessage` honors a per-call
`sendOptions.whenBusy` override over the client default, and verify `maxSize`
with `onOverflow: 'reject'` and `'drop-oldest'` behaves correctly. Use the
existing `ChatClient`, `sendMessage`, `getQueue`, `getMessages`, and the held
connection helpers as reference points, and add a boundary case for `maxSize: 0`
to cover the overflow path mentioned in `chat-client.ts`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/ai-client/src/chat-client.ts`:
- Around line 905-923: The enqueueMessage queue limit check currently lets one
item through when queueConfig.maxSize is 0 and onOverflow is not 'reject',
because it shifts an empty queue and then still pushes a new message. Update
enqueueMessage to treat maxSize: 0 as a hard cap in the same way as other
full-queue cases, using the existing messageQueue, queueConfig, and onOverflow
logic so no item is enqueued when the limit is zero.
- Around line 882-923: The queued item id is generated twice, so the
`pending.id` seen by `QueueStrategy` does not match the actual entry stored by
`enqueueMessage`. Generate one queued id in `decideWhenBusy`, pass it through to
`enqueueMessage`, and use that same id when building the `pending` context and
when pushing the message so `getQueue()`, `onQueueChange`, and
`cancelQueued(id)` all refer to the same item.

In `@packages/ai-svelte/src/types.ts`:
- Around line 149-152: The `queue` property in the chat state interface is
missing `readonly`, unlike the other reactive getters and the Svelte/Vue
equivalents. Update the `queue` declaration in `types.ts` to be readonly
alongside `messages`, `isLoading`, `error`, `status`, `isSubscribed`,
`connectionStatus`, and `sessionGenerating`, so consumers know it is getter-only
and cannot be assigned to directly.

---

Duplicate comments:
In `@packages/ai-solid/src/use-chat.ts`:
- Around line 141-144: The chat queue is only read once when the client is
created in useChat, so later changes to options.queue are never synced into the
live client. Add a createEffect alongside the existing
body/forwardedProps/context syncing that calls client().updateOptions(...)
whenever options.queue changes, using the same pattern as the other reactive
option updates in useChat so the queue stays in sync if it is meant to be
live-updatable.

---

Nitpick comments:
In `@packages/ai-client/src/chat-client.ts`:
- Around line 803-846: The public JSDoc for sendMessage is missing the new third
parameter, so update the documentation block for sendMessage to describe
sendOptions and its whenBusy option alongside content and body. Add a brief
`@param` entry and, if helpful, a small example showing how sendOptions.whenBusy
is used, so the docs match the new signature in chat-client.ts.

In `@packages/ai-client/tests/chat-client-queue.test.ts`:
- Around line 71-328: Add tests in ChatClient message queue coverage for the
missing documented behaviors: verify `whenBusy: 'interrupt'` aborts the
in-flight send and allows the new message to send immediately, verify
`sendMessage` honors a per-call `sendOptions.whenBusy` override over the client
default, and verify `maxSize` with `onOverflow: 'reject'` and `'drop-oldest'`
behaves correctly. Use the existing `ChatClient`, `sendMessage`, `getQueue`,
`getMessages`, and the held connection helpers as reference points, and add a
boundary case for `maxSize: 0` to cover the overflow path mentioned in
`chat-client.ts`.

In `@packages/ai-preact/src/use-chat.ts`:
- Around line 157-163: The `queue` option in `useChat` is only applied when the
client is created in the `useMemo` block, so later changes to `options.queue`
are ignored after mount. Update the `updateOptions` path in `use-chat.ts` (the
effect that already keeps `body`, `forwardedProps`, and `context` in sync) to
also propagate `queue` when `optionsRef.current.queue` changes, using the same
`activeClientRef.current === instance` guard as `onQueueChange`. If `queue` is
meant to stay one-time like `tools`/`persistence`, make that behavior explicit
in the option handling and docs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 384b81cf-49eb-43ca-a54f-13b3dc63cefd

📥 Commits

Reviewing files that changed from the base of the PR and between 405c9d4 and 6aa7207.

📒 Files selected for processing (28)
  • .changeset/message-queue.md
  • docs/chat/streaming.md
  • docs/config.json
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/src/devtools.ts
  • packages/ai-client/src/index.ts
  • packages/ai-client/src/types.ts
  • packages/ai-client/tests/chat-client-queue.test.ts
  • packages/ai-client/tests/chat-client.test.ts
  • packages/ai-preact/src/types.ts
  • packages/ai-preact/src/use-chat.ts
  • packages/ai-preact/tests/use-chat.test.ts
  • packages/ai-react/src/types.ts
  • packages/ai-react/src/use-chat.ts
  • packages/ai-react/tests/use-chat.test.ts
  • packages/ai-solid/src/types.ts
  • packages/ai-solid/src/use-chat.ts
  • packages/ai-solid/tests/use-chat.test.ts
  • packages/ai-svelte/src/create-chat.svelte.ts
  • packages/ai-svelte/src/types.ts
  • packages/ai-vue/src/types.ts
  • packages/ai-vue/src/use-chat.ts
  • packages/ai-vue/tests/use-chat.test.ts
  • packages/ai/skills/ai-core/chat-experience/SKILL.md
  • testing/e2e/fixtures/queue/basic.json
  • testing/e2e/src/components/ChatUI.tsx
  • testing/e2e/src/routes/$provider/$feature.tsx
  • testing/e2e/tests/queue.spec.ts

Comment on lines +882 to +923
private decideWhenBusy(
content: string | MultimodalContent,
sendOptions?: SendMessageOptions,
): 'drop' | 'enqueue' | 'interrupt' {
if (sendOptions?.whenBusy) {
return sendOptions.whenBusy === 'queue' ? 'enqueue' : sendOptions.whenBusy
}
const { strategy, whenBusy } = this.queueConfig
if (strategy) {
const { action } = strategy({
pending: {
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
},
isStreaming: true,
queued: this.getQueue(),
})
return action === 'send' ? 'enqueue' : action
}
return whenBusy === 'queue' ? 'enqueue' : whenBusy
}

private enqueueMessage(
content: string | MultimodalContent,
body?: Record<string, any>,
): void {
const { maxSize, onOverflow } = this.queueConfig
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
if (onOverflow === 'reject') {
return
}
this.messageQueue.shift() // drop-oldest
}
this.messageQueue.push({
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
...(body !== undefined ? { body } : {}),
})
this.emitQueueChange()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

pending.id shown to a QueueStrategy never matches the actual enqueued item's id.

decideWhenBusy generates one id (Line 893) to build the pending context passed to a custom QueueStrategy, but if the strategy returns 'enqueue', enqueueMessage (Line 916-921) generates a different id for the actual queued entry. Any strategy that inspects pending.id (e.g. to later cancelQueued(id) the very message it just classified) will silently fail to match, since the id it saw is never the one stored/exposed via getQueue()/onQueueChange.

🐛 Proposed fix: thread a single id through decideWhenBusy → enqueueMessage
   private decideWhenBusy(
     content: string | MultimodalContent,
     sendOptions?: SendMessageOptions,
-  ): 'drop' | 'enqueue' | 'interrupt' {
+  ): { action: 'drop' | 'enqueue' | 'interrupt'; id: string } {
+    const id = this.generateUniqueId('queued')
     if (sendOptions?.whenBusy) {
-      return sendOptions.whenBusy === 'queue' ? 'enqueue' : sendOptions.whenBusy
+      return {
+        action: sendOptions.whenBusy === 'queue' ? 'enqueue' : sendOptions.whenBusy,
+        id,
+      }
     }
     const { strategy, whenBusy } = this.queueConfig
     if (strategy) {
       const { action } = strategy({
-        pending: {
-          id: this.generateUniqueId('queued'),
-          content,
-          createdAt: Date.now(),
-        },
+        pending: { id, content, createdAt: Date.now() },
         isStreaming: true,
         queued: this.getQueue(),
       })
-      return action === 'send' ? 'enqueue' : action
+      return { action: action === 'send' ? 'enqueue' : action, id }
     }
-    return whenBusy === 'queue' ? 'enqueue' : whenBusy
+    return { action: whenBusy === 'queue' ? 'enqueue' : whenBusy, id }
   }

   private enqueueMessage(
     content: string | MultimodalContent,
     body?: Record<string, any>,
+    id?: string,
   ): void {
     const { maxSize, onOverflow } = this.queueConfig
     if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
       if (onOverflow === 'reject') {
         return
       }
       this.messageQueue.shift() // drop-oldest
     }
     this.messageQueue.push({
-      id: this.generateUniqueId('queued'),
+      id: id ?? this.generateUniqueId('queued'),
       content,
       createdAt: Date.now(),
       ...(body !== undefined ? { body } : {}),
     })
     this.emitQueueChange()
   }

And update the call site:

     if (this.isLoading) {
-      const action = this.decideWhenBusy(content, sendOptions)
+      const { action, id } = this.decideWhenBusy(content, sendOptions)
       if (action === 'drop') {
         return
       }
       if (action === 'enqueue') {
-        this.enqueueMessage(content, body)
+        this.enqueueMessage(content, body, id)
         return
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private decideWhenBusy(
content: string | MultimodalContent,
sendOptions?: SendMessageOptions,
): 'drop' | 'enqueue' | 'interrupt' {
if (sendOptions?.whenBusy) {
return sendOptions.whenBusy === 'queue' ? 'enqueue' : sendOptions.whenBusy
}
const { strategy, whenBusy } = this.queueConfig
if (strategy) {
const { action } = strategy({
pending: {
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
},
isStreaming: true,
queued: this.getQueue(),
})
return action === 'send' ? 'enqueue' : action
}
return whenBusy === 'queue' ? 'enqueue' : whenBusy
}
private enqueueMessage(
content: string | MultimodalContent,
body?: Record<string, any>,
): void {
const { maxSize, onOverflow } = this.queueConfig
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
if (onOverflow === 'reject') {
return
}
this.messageQueue.shift() // drop-oldest
}
this.messageQueue.push({
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
...(body !== undefined ? { body } : {}),
})
this.emitQueueChange()
}
private decideWhenBusy(
content: string | MultimodalContent,
sendOptions?: SendMessageOptions,
): { action: 'drop' | 'enqueue' | 'interrupt'; id: string } {
const id = this.generateUniqueId('queued')
if (sendOptions?.whenBusy) {
return {
action: sendOptions.whenBusy === 'queue' ? 'enqueue' : sendOptions.whenBusy,
id,
}
}
const { strategy, whenBusy } = this.queueConfig
if (strategy) {
const { action } = strategy({
pending: { id, content, createdAt: Date.now() },
isStreaming: true,
queued: this.getQueue(),
})
return { action: action === 'send' ? 'enqueue' : action, id }
}
return { action: whenBusy === 'queue' ? 'enqueue' : whenBusy, id }
}
private enqueueMessage(
content: string | MultimodalContent,
body?: Record<string, any>,
id?: string,
): void {
const { maxSize, onOverflow } = this.queueConfig
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
if (onOverflow === 'reject') {
return
}
this.messageQueue.shift() // drop-oldest
}
this.messageQueue.push({
id: id ?? this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
...(body !== undefined ? { body } : {}),
})
this.emitQueueChange()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/src/chat-client.ts` around lines 882 - 923, The queued
item id is generated twice, so the `pending.id` seen by `QueueStrategy` does not
match the actual entry stored by `enqueueMessage`. Generate one queued id in
`decideWhenBusy`, pass it through to `enqueueMessage`, and use that same id when
building the `pending` context and when pushing the message so `getQueue()`,
`onQueueChange`, and `cancelQueued(id)` all refer to the same item.

Comment on lines +905 to +923
private enqueueMessage(
content: string | MultimodalContent,
body?: Record<string, any>,
): void {
const { maxSize, onOverflow } = this.queueConfig
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
if (onOverflow === 'reject') {
return
}
this.messageQueue.shift() // drop-oldest
}
this.messageQueue.push({
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
...(body !== undefined ? { body } : {}),
})
this.emitQueueChange()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

maxSize: 0 with onOverflow: 'drop-oldest' still allows one item into the queue.

this.messageQueue.length >= maxSize is 0 >= 0 (true) on the very first enqueue, so it shifts (no-op on an empty queue) and then pushes anyway — leaving queue length 1, violating the documented "Max queued items" contract for the maxSize: 0 edge case.

🐛 Proposed fix
     if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
       if (onOverflow === 'reject') {
         return
       }
+      if (maxSize === 0) {
+        return
+      }
       this.messageQueue.shift() // drop-oldest
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private enqueueMessage(
content: string | MultimodalContent,
body?: Record<string, any>,
): void {
const { maxSize, onOverflow } = this.queueConfig
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
if (onOverflow === 'reject') {
return
}
this.messageQueue.shift() // drop-oldest
}
this.messageQueue.push({
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
...(body !== undefined ? { body } : {}),
})
this.emitQueueChange()
}
private enqueueMessage(
content: string | MultimodalContent,
body?: Record<string, any>,
): void {
const { maxSize, onOverflow } = this.queueConfig
if (maxSize !== undefined && this.messageQueue.length >= maxSize) {
if (onOverflow === 'reject') {
return
}
if (maxSize === 0) {
return
}
this.messageQueue.shift() // drop-oldest
}
this.messageQueue.push({
id: this.generateUniqueId('queued'),
content,
createdAt: Date.now(),
...(body !== undefined ? { body } : {}),
})
this.emitQueueChange()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-client/src/chat-client.ts` around lines 905 - 923, The
enqueueMessage queue limit check currently lets one item through when
queueConfig.maxSize is 0 and onOverflow is not 'reject', because it shifts an
empty queue and then still pushes a new message. Update enqueueMessage to treat
maxSize: 0 as a hard cap in the same way as other full-queue cases, using the
existing messageQueue, queueConfig, and onOverflow logic so no item is enqueued
when the limit is zero.

Comment on lines +149 to +152
/**
* Pending messages queued while a stream is in flight.
*/
queue: Array<QueuedMessage>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Mark queue as readonly for consistency.

Every other reactive getter in this interface (messages, isLoading, error, status, isSubscribed, connectionStatus, sessionGenerating) is declared readonly, and the Svelte implementation only exposes queue via a getter (no setter). The Vue equivalent is also wrapped in Readonly<...>. Missing readonly here lets consumers believe chat.queue = [...] is a valid assignment.

♻️ Proposed fix
   /**
    * Pending messages queued while a stream is in flight.
    */
-  queue: Array<QueuedMessage>
+  readonly queue: Array<QueuedMessage>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Pending messages queued while a stream is in flight.
*/
queue: Array<QueuedMessage>
/**
* Pending messages queued while a stream is in flight.
*/
readonly queue: Array<QueuedMessage>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-svelte/src/types.ts` around lines 149 - 152, The `queue` property
in the chat state interface is missing `readonly`, unlike the other reactive
getters and the Svelte/Vue equivalents. Update the `queue` declaration in
`types.ts` to be readonly alongside `messages`, `isLoading`, `error`, `status`,
`isSubscribed`, `connectionStatus`, and `sessionGenerating`, so consumers know
it is getter-only and cannot be assigned to directly.

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.

1 participant