Skip to content

Commit dc53e1b

Browse files
thrufloclaudeautofix-ci[bot]samwilliscursoragent
authored
feat(ai, ai-client): add SessionAdapter for durable session support. (TanStack#286)
* feat(ai): align start event types with AG-UI Widen TextMessageStartEvent.role to accept all message roles and add optional parentMessageId to ToolCallStartEvent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai): add MessageStreamState type for per-message stream tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai): refactor StreamProcessor to per-message state Replace single-message instance variables with a Map<string, MessageStreamState> keyed by messageId. Add explicit handlers for TEXT_MESSAGE_START, TEXT_MESSAGE_END, and STATE_SNAPSHOT events. Route tool calls via toolCallToMessage mapping. Maintains backward compat: startAssistantMessage() sets pendingManualMessageId which TEXT_MESSAGE_START associates with. ensureAssistantMessage() auto-creates state for streams without TEXT_MESSAGE_START. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai): replace STATE_SNAPSHOT with MESSAGES_SNAPSHOT event Add MessagesSnapshotEvent as a first-class AG-UI event type for conversation hydration. Replace the previous STATE_SNAPSHOT handler (which extracted messages from arbitrary state) with a dedicated MESSAGES_SNAPSHOT handler that accepts a typed messages array. - Add MessagesSnapshotEvent type to AGUIEventType and AGUIEvent unions - Add MESSAGES_SNAPSHOT case in StreamProcessor.processChunk() - Remove STATE_SNAPSHOT handler (falls through to default no-op) - Fix onStreamEnd to fire per-message (not only when no active messages remain) - Fix getActiveAssistantMessageId to return on first reverse match - Fix ensureAssistantMessage to emit onStreamStart and onMessagesChange - Add proposal docs for resumeable session support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai-client): add SessionAdapter interface and createDefaultSession Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai-client): refactor ChatClient to use SessionAdapter subscription model Replace direct ConnectionAdapter usage in ChatClient with a SessionAdapter-based subscription loop. When only a ConnectionAdapter is provided, it is wrapped in a DefaultSessionAdapter internally. This enables persistent session support while preserving existing timing semantics and backwards compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai-preact): thread option through. * fix(ai): finalizeStream when RUN_FINISHED. * fix(ai-client): handle reload during active stream with generation counter reload() now cancels the active stream (abort controllers, subscription, processing promise) before starting a new one. A stream generation counter prevents a superseded stream's async cleanup from clobbering the new stream's state (abortController, isLoading, processor). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: remove proposal docs. * fix(ai, ai-client): address stream lifecycle edge cases from PR review - Guard against double onStreamEnd when RUN_FINISHED arrives before TEXT_MESSAGE_END - Clear dead waiters on subscribe exit to prevent chunk loss on reconnection - Reset transient processor state (messageStates, activeMessageIds, etc.) on MESSAGES_SNAPSHOT - Remove optimistic startAssistantMessage() from streamResponse(); let stream events create the message naturally via TEXT_MESSAGE_START or ensureAssistantMessage() - Clean up abort listeners on normal waiter resolution to prevent listener accumulation - Make handleStepFinishedEvent use ensureAssistantMessage() for backward compat with streams that lack TEXT_MESSAGE_START Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai-client): fix reload failures from stale stream state and waiter race Reset processor stream state (prepareAssistantMessage) in streamResponse() before the subscription loop, preventing stale messageStates from blocking new assistant message creation on reload. Rewrite createDefaultSession with per-subscribe queue isolation: each subscribe() synchronously installs fresh buffer/waiters, drains pre-buffered chunks via splice(0), and removes async cleanup that raced with new subscription cycles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * fix(ai): resolve eslint errors in stream processor Remove unnecessary `chunk.delta !== undefined` condition (delta is always a string on TextMessageContentEvent) and remove redundant `!` non-null assertion inside an already-narrowed `if` block. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai-client): resolve eslint errors in chat-client and session-adapter Fix import ordering: move value import `createDefaultSession` above type-only imports. Convert shorthand method signatures to function property style in the SessionAdapter interface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai-client): propagate send() errors to subscribe() consumers Wrap createDefaultSession's send() in try/catch and push a RUN_ERROR AG-UI event to the queue before re-throwing, so subscribe() consumers learn about connection failures through the standard protocol. Also resolve processingResolve on RUN_ERROR in consumeSubscription (same as RUN_FINISHED) to prevent hangs. Tests updated: error assertions now check message content rather than referential identity, since errors flowing through RUN_ERROR create new Error instances from the message string. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai): map 'tool' role to 'assistant' in message state to fix lookups The stream processor mapped 'tool' to 'assistant' for UIMessage but stored the raw 'tool' role in MessageStreamState. This caused getActiveAssistantMessageId() and getCurrentAssistantMessageId() to miss tool-role messages, so subsequent stream events couldn't attach to the existing message. Now the uiRole mapping is applied consistently across all three cases in handleTextMessageStartEvent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai): normalize chunk.delta to avoid "undefined" string concatenation When chunk.delta was undefined, the check `chunk.delta !== ''` evaluated to true, causing "undefined" to be concatenated into nextText. Use `chunk.delta ?? ''` to normalize before comparison, matching the safe pattern already used in handleToolCallArgsEvent. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai): use || instead of ?? for chunk.delta fallback to satisfy eslint The no-unnecessary-condition rule flags ?? since TypeScript types delta as string. Using || preserves runtime safety and matches existing patterns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(ai): reset stream flags on MESSAGES_SNAPSHOT to avoid stale state handleMessagesSnapshotEvent was clearing maps but not resetting isDone, hasError, and finishReason. Use resetStreamState() which handles all of these, ensuring finalizeStream() sees fresh state after a snapshot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(ai-client): finalize connection adapter unification Squash all post-groundwork changes into a single commit that completes the connection adapter unification, stream lifecycle hardening, and restoration of ai stream snapshot/state behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * ci: apply automated fixes * fix: return booleans from chat client streamResponse Keep early stream exits aligned with the Promise<boolean> contract so repo-wide type checks pass after the merge from main. Made-with: Cursor * chore: add changeset for durable chat updates Document the patch releases for the durable subscribe/send chat transport, generation client wrappers, and core stream processing changes included on this branch. Made-with: Cursor * fix: resolve tool call approval after RUN_FINISHED using toolCallToMessage fallback After RUN_FINISHED, activeMessageIds is cleared so tool call approval couldn't find the parent message. Fall back to the toolCallToMessage map which is populated during TOOL_CALL_START and preserved across finalize. Also adds missing role field to mock chat scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Sam Willis <sam.willis@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Jack Herrington <jherr@pobox.com>
1 parent 0e21282 commit dc53e1b

43 files changed

Lines changed: 2091 additions & 551 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/tidy-zebras-drum.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@tanstack/ai': patch
3+
'@tanstack/ai-client': patch
4+
'@tanstack/ai-react': patch
5+
'@tanstack/ai-solid': patch
6+
'@tanstack/ai-svelte': patch
7+
'@tanstack/ai-vue': patch
8+
---
9+
10+
Add durable `subscribe()`/`send()` transport support to `ChatClient` while preserving compatibility with existing `connect()` adapters. This also introduces shared generation clients for one-shot streaming tasks and updates the framework wrappers to use the new generation transport APIs.
11+
12+
Improve core stream processing to better handle concurrent runs and resumed streams so shared sessions stay consistent during reconnects and overlapping generations.

examples/ts-react-chat/src/routeTree.gen.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010

1111
import { Route as rootRouteImport } from './routes/__root'
12-
import { Route as ImageGenRouteImport } from './routes/image-gen'
1312
import { Route as RealtimeRouteImport } from './routes/realtime'
13+
import { Route as ImageGenRouteImport } from './routes/image-gen'
1414
import { Route as IndexRouteImport } from './routes/index'
1515
import { Route as GenerationsVideoRouteImport } from './routes/generations.video'
1616
import { Route as GenerationsTranscriptionRouteImport } from './routes/generations.transcription'
@@ -19,24 +19,24 @@ import { Route as GenerationsSpeechRouteImport } from './routes/generations.spee
1919
import { Route as GenerationsImageRouteImport } from './routes/generations.image'
2020
import { Route as ApiTranscribeRouteImport } from './routes/api.transcribe'
2121
import { Route as ApiTanchatRouteImport } from './routes/api.tanchat'
22-
import { Route as ApiImageGenRouteImport } from './routes/api.image-gen'
2322
import { Route as ApiSummarizeRouteImport } from './routes/api.summarize'
23+
import { Route as ApiImageGenRouteImport } from './routes/api.image-gen'
2424
import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index'
2525
import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId'
2626
import { Route as ApiGenerateVideoRouteImport } from './routes/api.generate.video'
2727
import { Route as ApiGenerateSpeechRouteImport } from './routes/api.generate.speech'
2828
import { Route as ApiGenerateImageRouteImport } from './routes/api.generate.image'
2929

30-
const ImageGenRoute = ImageGenRouteImport.update({
31-
id: '/image-gen',
32-
path: '/image-gen',
33-
getParentRoute: () => rootRouteImport,
34-
} as any)
3530
const RealtimeRoute = RealtimeRouteImport.update({
3631
id: '/realtime',
3732
path: '/realtime',
3833
getParentRoute: () => rootRouteImport,
3934
} as any)
35+
const ImageGenRoute = ImageGenRouteImport.update({
36+
id: '/image-gen',
37+
path: '/image-gen',
38+
getParentRoute: () => rootRouteImport,
39+
} as any)
4040
const IndexRoute = IndexRouteImport.update({
4141
id: '/',
4242
path: '/',
@@ -78,16 +78,16 @@ const ApiTanchatRoute = ApiTanchatRouteImport.update({
7878
path: '/api/tanchat',
7979
getParentRoute: () => rootRouteImport,
8080
} as any)
81-
const ApiImageGenRoute = ApiImageGenRouteImport.update({
82-
id: '/api/image-gen',
83-
path: '/api/image-gen',
84-
getParentRoute: () => rootRouteImport,
85-
} as any)
8681
const ApiSummarizeRoute = ApiSummarizeRouteImport.update({
8782
id: '/api/summarize',
8883
path: '/api/summarize',
8984
getParentRoute: () => rootRouteImport,
9085
} as any)
86+
const ApiImageGenRoute = ApiImageGenRouteImport.update({
87+
id: '/api/image-gen',
88+
path: '/api/image-gen',
89+
getParentRoute: () => rootRouteImport,
90+
} as any)
9191
const ExampleGuitarsIndexRoute = ExampleGuitarsIndexRouteImport.update({
9292
id: '/example/guitars/',
9393
path: '/example/guitars/',
@@ -254,20 +254,20 @@ export interface RootRouteChildren {
254254

255255
declare module '@tanstack/react-router' {
256256
interface FileRoutesByPath {
257-
'/image-gen': {
258-
id: '/image-gen'
259-
path: '/image-gen'
260-
fullPath: '/image-gen'
261-
preLoaderRoute: typeof ImageGenRouteImport
262-
parentRoute: typeof rootRouteImport
263-
}
264257
'/realtime': {
265258
id: '/realtime'
266259
path: '/realtime'
267260
fullPath: '/realtime'
268261
preLoaderRoute: typeof RealtimeRouteImport
269262
parentRoute: typeof rootRouteImport
270263
}
264+
'/image-gen': {
265+
id: '/image-gen'
266+
path: '/image-gen'
267+
fullPath: '/image-gen'
268+
preLoaderRoute: typeof ImageGenRouteImport
269+
parentRoute: typeof rootRouteImport
270+
}
271271
'/': {
272272
id: '/'
273273
path: '/'
@@ -324,20 +324,20 @@ declare module '@tanstack/react-router' {
324324
preLoaderRoute: typeof ApiTanchatRouteImport
325325
parentRoute: typeof rootRouteImport
326326
}
327-
'/api/image-gen': {
328-
id: '/api/image-gen'
329-
path: '/api/image-gen'
330-
fullPath: '/api/image-gen'
331-
preLoaderRoute: typeof ApiImageGenRouteImport
332-
parentRoute: typeof rootRouteImport
333-
}
334327
'/api/summarize': {
335328
id: '/api/summarize'
336329
path: '/api/summarize'
337330
fullPath: '/api/summarize'
338331
preLoaderRoute: typeof ApiSummarizeRouteImport
339332
parentRoute: typeof rootRouteImport
340333
}
334+
'/api/image-gen': {
335+
id: '/api/image-gen'
336+
path: '/api/image-gen'
337+
fullPath: '/api/image-gen'
338+
preLoaderRoute: typeof ApiImageGenRouteImport
339+
parentRoute: typeof rootRouteImport
340+
}
341341
'/example/guitars/': {
342342
id: '/example/guitars/'
343343
path: '/example/guitars'

0 commit comments

Comments
 (0)