@tanstack/ai-client 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,13 +15,13 @@ class ChatClientEventEmitter {
15
15
  * Emit loading state changed event
16
16
  */
17
17
  loadingChanged(isLoading) {
18
- this.emitEvent("client:loading-changed", { isLoading });
18
+ this.emitEvent("client:loading:changed", { isLoading });
19
19
  }
20
20
  /**
21
21
  * Emit error state changed event
22
22
  */
23
23
  errorChanged(error) {
24
- this.emitEvent("client:error-changed", {
24
+ this.emitEvent("client:error:changed", {
25
25
  error
26
26
  });
27
27
  }
@@ -29,11 +29,8 @@ class ChatClientEventEmitter {
29
29
  * Emit text update events (combines processor and client events)
30
30
  */
31
31
  textUpdated(streamId, messageId, content) {
32
- this.emitEvent("processor:text-updated", {
32
+ this.emitEvent("text:chunk:content", {
33
33
  streamId,
34
- content
35
- });
36
- this.emitEvent("client:assistant-message-updated", {
37
34
  messageId,
38
35
  content
39
36
  });
@@ -42,14 +39,8 @@ class ChatClientEventEmitter {
42
39
  * Emit tool call state change events (combines processor and client events)
43
40
  */
44
41
  toolCallStateChanged(streamId, messageId, toolCallId, toolName, state, args) {
45
- this.emitEvent("processor:tool-call-state-changed", {
42
+ this.emitEvent("tools:call:updated", {
46
43
  streamId,
47
- toolCallId,
48
- toolName,
49
- state,
50
- arguments: args
51
- });
52
- this.emitEvent("client:tool-call-updated", {
53
44
  messageId,
54
45
  toolCallId,
55
46
  toolName,
@@ -60,20 +51,11 @@ class ChatClientEventEmitter {
60
51
  /**
61
52
  * Emit tool result state change event
62
53
  */
63
- toolResultStateChanged(streamId, toolCallId, content, state, error) {
64
- this.emitEvent("processor:tool-result-state-changed", {
65
- streamId,
66
- toolCallId,
67
- content,
68
- state,
69
- error
70
- });
71
- }
72
54
  /**
73
55
  * Emit thinking update event
74
56
  */
75
57
  thinkingUpdated(streamId, messageId, content, delta) {
76
- this.emitEvent("stream:chunk:thinking", {
58
+ this.emitEvent("text:chunk:thinking", {
77
59
  streamId,
78
60
  messageId,
79
61
  content,
@@ -83,8 +65,9 @@ class ChatClientEventEmitter {
83
65
  /**
84
66
  * Emit approval requested event
85
67
  */
86
- approvalRequested(messageId, toolCallId, toolName, input, approvalId) {
87
- this.emitEvent("client:approval-requested", {
68
+ approvalRequested(streamId, messageId, toolCallId, toolName, input, approvalId) {
69
+ this.emitEvent("tools:approval:requested", {
70
+ streamId,
88
71
  messageId,
89
72
  toolCallId,
90
73
  toolName,
@@ -95,20 +78,28 @@ class ChatClientEventEmitter {
95
78
  /**
96
79
  * Emit message appended event
97
80
  */
98
- messageAppended(uiMessage) {
99
- const contentPreview = uiMessage.parts.filter((p) => p.type === "text").map((p) => p.content).join(" ").substring(0, 100);
100
- this.emitEvent("client:message-appended", {
81
+ messageAppended(uiMessage, streamId) {
82
+ const content = uiMessage.parts.filter((part) => part.type === "text").map((part) => part.content).join(" ");
83
+ this.emitEvent("text:message:created", {
84
+ streamId,
101
85
  messageId: uiMessage.id,
102
86
  role: uiMessage.role,
103
- contentPreview
87
+ content,
88
+ parts: uiMessage.parts
104
89
  });
105
90
  }
106
91
  /**
107
92
  * Emit message sent event
108
93
  */
109
94
  messageSent(messageId, content) {
110
- this.emitEvent("client:message-sent", {
95
+ this.emitEvent("text:message:created", {
96
+ messageId,
97
+ role: "user",
98
+ content
99
+ });
100
+ this.emitEvent("text:message:user", {
111
101
  messageId,
102
+ role: "user",
112
103
  content
113
104
  });
114
105
  }
@@ -130,13 +121,13 @@ class ChatClientEventEmitter {
130
121
  * Emit messages cleared event
131
122
  */
132
123
  messagesCleared() {
133
- this.emitEvent("client:messages-cleared");
124
+ this.emitEvent("client:messages:cleared");
134
125
  }
135
126
  /**
136
127
  * Emit tool result added event
137
128
  */
138
129
  toolResultAdded(toolCallId, toolName, output, state) {
139
- this.emitEvent("tool:result-added", {
130
+ this.emitEvent("tools:result:added", {
140
131
  toolCallId,
141
132
  toolName,
142
133
  output,
@@ -147,7 +138,7 @@ class ChatClientEventEmitter {
147
138
  * Emit tool approval responded event
148
139
  */
149
140
  toolApprovalResponded(approvalId, toolCallId, approved) {
150
- this.emitEvent("tool:approval-responded", {
141
+ this.emitEvent("tools:approval:responded", {
151
142
  approvalId,
152
143
  toolCallId,
153
144
  approved
@@ -159,10 +150,11 @@ class DefaultChatClientEventEmitter extends ChatClientEventEmitter {
159
150
  * Emit an event with automatic clientId and timestamp for client/tool events
160
151
  */
161
152
  emitEvent(eventName, data) {
162
- if (eventName.startsWith("client:") || eventName.startsWith("tool:")) {
153
+ if (eventName.startsWith("client:") || eventName.startsWith("tools:") || eventName.startsWith("text:")) {
163
154
  aiEventClient.emit(eventName, {
164
155
  ...data,
165
156
  clientId: this.clientId,
157
+ source: "client",
166
158
  timestamp: Date.now()
167
159
  });
168
160
  } else {
@@ -1 +1 @@
1
- {"version":3,"file":"events.js","sources":["../../src/events.ts"],"sourcesContent":["import { aiEventClient } from '@tanstack/ai/event-client'\nimport type { UIMessage } from './types'\n\n/**\n * Abstract base class for ChatClient event emission\n */\nexport abstract class ChatClientEventEmitter {\n protected clientId: string\n\n constructor(clientId: string) {\n this.clientId = clientId\n }\n\n /**\n * Protected abstract method for emitting events\n * Implementations should handle adding clientId and timestamp\n */\n protected abstract emitEvent(\n eventName: string,\n data?: Record<string, any>,\n ): void\n\n /**\n * Emit client created event\n */\n clientCreated(initialMessageCount: number): void {\n this.emitEvent('client:created', {\n initialMessageCount,\n })\n }\n\n /**\n * Emit loading state changed event\n */\n loadingChanged(isLoading: boolean): void {\n this.emitEvent('client:loading-changed', { isLoading })\n }\n\n /**\n * Emit error state changed event\n */\n errorChanged(error: string | null): void {\n this.emitEvent('client:error-changed', {\n error,\n })\n }\n\n /**\n * Emit text update events (combines processor and client events)\n */\n textUpdated(streamId: string, messageId: string, content: string): void {\n this.emitEvent('processor:text-updated', {\n streamId,\n content,\n })\n\n this.emitEvent('client:assistant-message-updated', {\n messageId,\n content,\n })\n }\n\n /**\n * Emit tool call state change events (combines processor and client events)\n */\n toolCallStateChanged(\n streamId: string,\n messageId: string,\n toolCallId: string,\n toolName: string,\n state: string,\n args: string,\n ): void {\n this.emitEvent('processor:tool-call-state-changed', {\n streamId,\n toolCallId,\n toolName,\n state,\n arguments: args,\n })\n\n this.emitEvent('client:tool-call-updated', {\n messageId,\n toolCallId,\n toolName,\n state,\n arguments: args,\n })\n }\n\n /**\n * Emit tool result state change event\n */\n toolResultStateChanged(\n streamId: string,\n toolCallId: string,\n content: string,\n state: string,\n error?: string,\n ): void {\n this.emitEvent('processor:tool-result-state-changed', {\n streamId,\n toolCallId,\n content,\n state,\n error,\n })\n }\n\n /**\n * Emit thinking update event\n */\n thinkingUpdated(\n streamId: string,\n messageId: string,\n content: string,\n delta?: string,\n ): void {\n this.emitEvent('stream:chunk:thinking', {\n streamId,\n messageId,\n content,\n delta,\n })\n }\n\n /**\n * Emit approval requested event\n */\n approvalRequested(\n messageId: string,\n toolCallId: string,\n toolName: string,\n input: any,\n approvalId: string,\n ): void {\n this.emitEvent('client:approval-requested', {\n messageId,\n toolCallId,\n toolName,\n input,\n approvalId,\n })\n }\n\n /**\n * Emit message appended event\n */\n messageAppended(uiMessage: UIMessage): void {\n const contentPreview = uiMessage.parts\n .filter((p) => p.type === 'text')\n .map((p) => (p as any).content)\n .join(' ')\n .substring(0, 100)\n\n this.emitEvent('client:message-appended', {\n messageId: uiMessage.id,\n role: uiMessage.role,\n contentPreview,\n })\n }\n\n /**\n * Emit message sent event\n */\n messageSent(messageId: string, content: string): void {\n this.emitEvent('client:message-sent', {\n messageId,\n content,\n })\n }\n\n /**\n * Emit reloaded event\n */\n reloaded(fromMessageIndex: number): void {\n this.emitEvent('client:reloaded', {\n fromMessageIndex,\n })\n }\n\n /**\n * Emit stopped event\n */\n stopped(): void {\n this.emitEvent('client:stopped')\n }\n\n /**\n * Emit messages cleared event\n */\n messagesCleared(): void {\n this.emitEvent('client:messages-cleared')\n }\n\n /**\n * Emit tool result added event\n */\n toolResultAdded(\n toolCallId: string,\n toolName: string,\n output: any,\n state: string,\n ): void {\n this.emitEvent('tool:result-added', {\n toolCallId,\n toolName,\n output,\n state,\n })\n }\n\n /**\n * Emit tool approval responded event\n */\n toolApprovalResponded(\n approvalId: string,\n toolCallId: string,\n approved: boolean,\n ): void {\n this.emitEvent('tool:approval-responded', {\n approvalId,\n toolCallId,\n approved,\n })\n }\n}\n\n/**\n * Default implementation of ChatClientEventEmitter\n */\nexport class DefaultChatClientEventEmitter extends ChatClientEventEmitter {\n /**\n * Emit an event with automatic clientId and timestamp for client/tool events\n */\n protected emitEvent(eventName: string, data?: Record<string, any>): void {\n // For client:* and tool:* events, automatically add clientId and timestamp\n if (eventName.startsWith('client:') || eventName.startsWith('tool:')) {\n aiEventClient.emit(eventName as any, {\n ...data,\n clientId: this.clientId,\n timestamp: Date.now(),\n })\n } else {\n // For other events (e.g., processor:*), just add timestamp\n aiEventClient.emit(eventName as any, {\n ...data,\n timestamp: Date.now(),\n })\n }\n }\n}\n"],"names":[],"mappings":";AAMO,MAAe,uBAAuB;AAAA,EAG3C,YAAY,UAAkB;AAC5B,SAAK,WAAW;AAAA,EAClB;AAAA;AAAA;AAAA;AAAA,EAcA,cAAc,qBAAmC;AAC/C,SAAK,UAAU,kBAAkB;AAAA,MAC/B;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,eAAe,WAA0B;AACvC,SAAK,UAAU,0BAA0B,EAAE,UAAA,CAAW;AAAA,EACxD;AAAA;AAAA;AAAA;AAAA,EAKA,aAAa,OAA4B;AACvC,SAAK,UAAU,wBAAwB;AAAA,MACrC;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,YAAY,UAAkB,WAAmB,SAAuB;AACtE,SAAK,UAAU,0BAA0B;AAAA,MACvC;AAAA,MACA;AAAA,IAAA,CACD;AAED,SAAK,UAAU,oCAAoC;AAAA,MACjD;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,qBACE,UACA,WACA,YACA,UACA,OACA,MACM;AACN,SAAK,UAAU,qCAAqC;AAAA,MAClD;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW;AAAA,IAAA,CACZ;AAED,SAAK,UAAU,4BAA4B;AAAA,MACzC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,WAAW;AAAA,IAAA,CACZ;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,uBACE,UACA,YACA,SACA,OACA,OACM;AACN,SAAK,UAAU,uCAAuC;AAAA,MACpD;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,gBACE,UACA,WACA,SACA,OACM;AACN,SAAK,UAAU,yBAAyB;AAAA,MACtC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,kBACE,WACA,YACA,UACA,OACA,YACM;AACN,SAAK,UAAU,6BAA6B;AAAA,MAC1C;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,gBAAgB,WAA4B;AAC1C,UAAM,iBAAiB,UAAU,MAC9B,OAAO,CAAC,MAAM,EAAE,SAAS,MAAM,EAC/B,IAAI,CAAC,MAAO,EAAU,OAAO,EAC7B,KAAK,GAAG,EACR,UAAU,GAAG,GAAG;AAEnB,SAAK,UAAU,2BAA2B;AAAA,MACxC,WAAW,UAAU;AAAA,MACrB,MAAM,UAAU;AAAA,MAChB;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,YAAY,WAAmB,SAAuB;AACpD,SAAK,UAAU,uBAAuB;AAAA,MACpC;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,SAAS,kBAAgC;AACvC,SAAK,UAAU,mBAAmB;AAAA,MAChC;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,UAAgB;AACd,SAAK,UAAU,gBAAgB;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA,EAKA,kBAAwB;AACtB,SAAK,UAAU,yBAAyB;AAAA,EAC1C;AAAA;AAAA;AAAA;AAAA,EAKA,gBACE,YACA,UACA,QACA,OACM;AACN,SAAK,UAAU,qBAAqB;AAAA,MAClC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,sBACE,YACA,YACA,UACM;AACN,SAAK,UAAU,2BAA2B;AAAA,MACxC;AAAA,MACA;AAAA,MACA;AAAA,IAAA,CACD;AAAA,EACH;AACF;AAKO,MAAM,sCAAsC,uBAAuB;AAAA;AAAA;AAAA;AAAA,EAI9D,UAAU,WAAmB,MAAkC;AAEvE,QAAI,UAAU,WAAW,SAAS,KAAK,UAAU,WAAW,OAAO,GAAG;AACpE,oBAAc,KAAK,WAAkB;AAAA,QACnC,GAAG;AAAA,QACH,UAAU,KAAK;AAAA,QACf,WAAW,KAAK,IAAA;AAAA,MAAI,CACrB;AAAA,IACH,OAAO;AAEL,oBAAc,KAAK,WAAkB;AAAA,QACnC,GAAG;AAAA,QACH,WAAW,KAAK,IAAA;AAAA,MAAI,CACrB;AAAA,IACH;AAAA,EACF;AACF;"}
1
+ {"version":3,"file":"events.js","sources":["../../src/events.ts"],"sourcesContent":["import { aiEventClient } from '@tanstack/ai/event-client'\nimport type { UIMessage } from './types'\n\n/**\n * Abstract base class for ChatClient event emission\n */\nexport abstract class ChatClientEventEmitter {\n protected clientId: string\n\n constructor(clientId: string) {\n this.clientId = clientId\n }\n\n /**\n * Protected abstract method for emitting events\n * Implementations should handle adding clientId and timestamp\n */\n protected abstract emitEvent(\n eventName: string,\n data?: Record<string, unknown>,\n ): void\n\n /**\n * Emit client created event\n */\n clientCreated(initialMessageCount: number): void {\n this.emitEvent('client:created', {\n initialMessageCount,\n })\n }\n\n /**\n * Emit loading state changed event\n */\n loadingChanged(isLoading: boolean): void {\n this.emitEvent('client:loading:changed', { isLoading })\n }\n\n /**\n * Emit error state changed event\n */\n errorChanged(error: string | null): void {\n this.emitEvent('client:error:changed', {\n error,\n })\n }\n\n /**\n * Emit text update events (combines processor and client events)\n */\n textUpdated(streamId: string, messageId: string, content: string): void {\n this.emitEvent('text:chunk:content', {\n streamId,\n messageId,\n content,\n })\n }\n\n /**\n * Emit tool call state change events (combines processor and client events)\n */\n toolCallStateChanged(\n streamId: string,\n messageId: string,\n toolCallId: string,\n toolName: string,\n state: string,\n args: string,\n ): void {\n this.emitEvent('tools:call:updated', {\n streamId,\n messageId,\n toolCallId,\n toolName,\n state,\n arguments: args,\n })\n }\n\n /**\n * Emit tool result state change event\n */\n /**\n * Emit thinking update event\n */\n thinkingUpdated(\n streamId: string,\n messageId: string,\n content: string,\n delta?: string,\n ): void {\n this.emitEvent('text:chunk:thinking', {\n streamId,\n messageId,\n content,\n delta,\n })\n }\n\n /**\n * Emit approval requested event\n */\n approvalRequested(\n streamId: string,\n messageId: string,\n toolCallId: string,\n toolName: string,\n input: unknown,\n approvalId: string,\n ): void {\n this.emitEvent('tools:approval:requested', {\n streamId,\n messageId,\n toolCallId,\n toolName,\n input,\n approvalId,\n })\n }\n\n /**\n * Emit message appended event\n */\n messageAppended(uiMessage: UIMessage, streamId?: string): void {\n const content = uiMessage.parts\n .filter((part) => part.type === 'text')\n .map((part) => part.content)\n .join(' ')\n\n this.emitEvent('text:message:created', {\n streamId,\n messageId: uiMessage.id,\n role: uiMessage.role,\n content,\n parts: uiMessage.parts,\n })\n }\n\n /**\n * Emit message sent event\n */\n messageSent(messageId: string, content: string): void {\n this.emitEvent('text:message:created', {\n messageId,\n role: 'user',\n content,\n })\n\n this.emitEvent('text:message:user', {\n messageId,\n role: 'user',\n content,\n })\n }\n\n /**\n * Emit reloaded event\n */\n reloaded(fromMessageIndex: number): void {\n this.emitEvent('client:reloaded', {\n fromMessageIndex,\n })\n }\n\n /**\n * Emit stopped event\n */\n stopped(): void {\n this.emitEvent('client:stopped')\n }\n\n /**\n * Emit messages cleared event\n */\n messagesCleared(): void {\n this.emitEvent('client:messages:cleared')\n }\n\n /**\n * Emit tool result added event\n */\n toolResultAdded(\n toolCallId: string,\n toolName: string,\n output: unknown,\n state: string,\n ): void {\n this.emitEvent('tools:result:added', {\n toolCallId,\n toolName,\n output,\n state,\n })\n }\n\n /**\n * Emit tool approval responded event\n */\n toolApprovalResponded(\n approvalId: string,\n toolCallId: string,\n approved: boolean,\n ): void {\n this.emitEvent('tools:approval:responded', {\n approvalId,\n toolCallId,\n approved,\n })\n }\n}\n\n/**\n * Default implementation of ChatClientEventEmitter\n */\nexport class DefaultChatClientEventEmitter extends ChatClientEventEmitter {\n /**\n * Emit an event with automatic clientId and timestamp for client/tool events\n */\n protected emitEvent(eventName: string, data?: Record<string, any>): void {\n // For client:* and tool:* events, automatically add clientId and timestamp\n if (\n eventName.startsWith('client:') ||\n eventName.startsWith('tools:') ||\n eventName.startsWith('text:')\n ) {\n aiEventClient.emit(eventName as any, {\n ...data,\n clientId: this.clientId,\n source: 'client',\n timestamp: Date.now(),\n })\n } else {\n // For other events, just add timestamp\n aiEventClient.emit(eventName as any, {\n ...data,\n timestamp: Date.now(),\n })\n }\n }\n}\n"],"names":[],"mappings":"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"}
@@ -1,8 +1,8 @@
1
1
  export { ChatClient } from './chat-client.js';
2
- export type { UIMessage, MessagePart, TextPart, ToolCallPart, ToolResultPart, ThinkingPart, ChatClientOptions, ChatRequestBody, InferChatMessages, } from './types.js';
2
+ export type { UIMessage, MessagePart, TextPart, ToolCallPart, ToolResultPart, ThinkingPart, ChatClientOptions, ChatRequestBody, InferChatMessages, ChatClientState, } from './types.js';
3
3
  export { clientTools, createChatClientOptions } from './types.js';
4
4
  export type { ExtractToolNames, ExtractToolInput, ExtractToolOutput, } from './tool-types.js';
5
5
  export type { AnyClientTool } from '@tanstack/ai';
6
6
  export { fetchServerSentEvents, fetchHttpStream, stream, rpcStream, type ConnectionAdapter, type FetchConnectionOptions, } from './connection-adapters.js';
7
7
  export { uiMessageToModelMessages, modelMessageToUIMessage, modelMessagesToUIMessages, convertMessagesToModelMessages, normalizeToUIMessage, generateMessageId, } from '@tanstack/ai';
8
- export { StreamProcessor, ImmediateStrategy, PunctuationStrategy, BatchStrategy, WordBoundaryStrategy, CompositeStrategy, parsePartialJSON, PartialJSONParser, defaultJSONParser, type ChunkStrategy, type StreamProcessorOptions, type StreamProcessorHandlers, type StreamProcessorEvents, type InternalToolCallState, type ToolCallState, type ToolResultState, type JSONParser, type ChunkRecording, type ProcessorResult, type ProcessorState, } from '@tanstack/ai';
8
+ export { StreamProcessor, ImmediateStrategy, PunctuationStrategy, BatchStrategy, WordBoundaryStrategy, CompositeStrategy, parsePartialJSON, PartialJSONParser, defaultJSONParser, type ChunkStrategy, type StreamProcessorOptions, type StreamProcessorEvents, type InternalToolCallState, type ToolCallState, type ToolResultState, type JSONParser, type ChunkRecording, type ProcessorResult, type ProcessorState, } from '@tanstack/ai';
@@ -8,6 +8,10 @@ export type ToolCallState = 'awaiting-input' | 'input-streaming' | 'input-comple
8
8
  * Tool result states - track the lifecycle of a tool result
9
9
  */
10
10
  export type ToolResultState = 'streaming' | 'complete' | 'error';
11
+ /**
12
+ * ChatClient state - track the lifecycle of a chat
13
+ */
14
+ export type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error';
11
15
  /**
12
16
  * Message parts - building blocks of UIMessage
13
17
  */
@@ -138,6 +142,10 @@ export interface ChatClientOptions<TTools extends ReadonlyArray<AnyClientTool> =
138
142
  * Callback when error state changes
139
143
  */
140
144
  onErrorChange?: (error: Error | undefined) => void;
145
+ /**
146
+ * Callback when chat status changes
147
+ */
148
+ onStatusChange?: (status: ChatClientState) => void;
141
149
  /**
142
150
  * Client-side tools with execution logic
143
151
  * When provided, tools with execute functions will be called automatically
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n ChunkStrategy,\n InferToolInput,\n InferToolOutput,\n ModelMessage,\n StreamChunk,\n} from '@tanstack/ai'\nimport type { ConnectionAdapter } from './connection-adapters'\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Approval metadata if tool requires user approval */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n }\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n | TextPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results\n */\nexport interface UIMessage<TTools extends ReadonlyArray<AnyClientTool> = any> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools>>\n createdAt?: Date\n}\n\nexport interface ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n /**\n * Connection adapter for streaming\n * Use fetchServerSentEvents(), fetchHttpStream(), or stream() to create adapters\n */\n connection: ConnectionAdapter\n\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Unique identifier for this chat instance\n * Used for managing multiple chats\n */\n id?: string\n\n /**\n * Additional body parameters to send\n */\n body?: Record<string, any>\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n>(options: ChatClientOptions<TTools>): ChatClientOptions<TTools> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools> ? Array<UIMessage<TTools>> : never\n"],"names":[],"mappings":"AAwOO,SAAS,eACX,OACA;AACH,SAAO;AACT;AAkBO,SAAS,wBAEd,SAA+D;AAC/D,SAAO;AACT;"}
1
+ {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n AnyClientTool,\n ChunkStrategy,\n InferToolInput,\n InferToolOutput,\n ModelMessage,\n StreamChunk,\n} from '@tanstack/ai'\nimport type { ConnectionAdapter } from './connection-adapters'\n\n/**\n * Tool call states - track the lifecycle of a tool call\n */\nexport type ToolCallState =\n | 'awaiting-input' // Received start but no arguments yet\n | 'input-streaming' // Partial arguments received\n | 'input-complete' // All arguments received\n | 'approval-requested' // Waiting for user approval\n | 'approval-responded' // User has approved/denied\n\n/**\n * Tool result states - track the lifecycle of a tool result\n */\nexport type ToolResultState =\n | 'streaming' // Placeholder for future streamed output\n | 'complete' // Result is complete\n | 'error' // Error occurred\n\n/**\n * ChatClient state - track the lifecycle of a chat\n */\nexport type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'\n\n/**\n * Message parts - building blocks of UIMessage\n */\nexport interface TextPart {\n type: 'text'\n content: string\n}\n\n/**\n * Helper type that creates a tool-call part for a specific tool.\n * This is a conditional type to enable proper distribution over union types,\n * creating a discriminated union where `name` is the discriminant.\n */\ntype ToolCallPartForTool<T> = T extends AnyClientTool\n ? {\n type: 'tool-call'\n id: string\n name: T['name']\n arguments: string // JSON string (may be incomplete)\n /** Parsed tool input (typed from inputSchema) */\n input?: InferToolInput<T>\n state: ToolCallState\n /** Approval metadata if tool requires user approval */\n approval?: {\n id: string // Unique approval ID\n needsApproval: boolean // Always true if present\n approved?: boolean // User's decision (undefined until responded)\n }\n /** Tool execution output (for client tools or after approval) */\n output?: InferToolOutput<T>\n }\n : never\n\n/**\n * Fallback tool-call part type when tools are not typed\n */\ntype UntypedToolCallPart = {\n type: 'tool-call'\n id: string\n name: string\n arguments: string\n input?: any\n state: ToolCallState\n approval?: {\n id: string\n needsApproval: boolean\n approved?: boolean\n }\n output?: any\n}\n\n/**\n * Tool call part that creates a proper discriminated union.\n * When TTools is typed, checking `part.name === 'toolName'` will narrow\n * `part.output` to the correct type for that tool.\n *\n * The discriminant is `name`, so code like:\n * ```ts\n * if (part.name === 'recommendGuitar') {\n * // part.output is now typed to the recommendGuitar tool's output\n * }\n * ```\n */\nexport type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n // Check if we have a concrete tools array (not 'any' or 'never')\n [TTools] extends [never]\n ? UntypedToolCallPart\n : unknown extends TTools\n ? UntypedToolCallPart\n : TTools extends ReadonlyArray<infer Tool>\n ? Tool extends AnyClientTool\n ? ToolCallPartForTool<Tool>\n : UntypedToolCallPart\n : UntypedToolCallPart\n\nexport interface ToolResultPart {\n type: 'tool-result'\n toolCallId: string\n content: string\n state: ToolResultState\n error?: string // Error message if state is \"error\"\n}\n\nexport interface ThinkingPart {\n type: 'thinking'\n content: string\n}\n\nexport type MessagePart<TTools extends ReadonlyArray<AnyClientTool> = any> =\n | TextPart\n | ToolCallPart<TTools>\n | ToolResultPart\n | ThinkingPart\n\n/**\n * UIMessage - Domain-specific message format optimized for building chat UIs\n * Contains parts that can be text, tool calls, or tool results\n */\nexport interface UIMessage<TTools extends ReadonlyArray<AnyClientTool> = any> {\n id: string\n role: 'system' | 'user' | 'assistant'\n parts: Array<MessagePart<TTools>>\n createdAt?: Date\n}\n\nexport interface ChatClientOptions<\n TTools extends ReadonlyArray<AnyClientTool> = any,\n> {\n /**\n * Connection adapter for streaming\n * Use fetchServerSentEvents(), fetchHttpStream(), or stream() to create adapters\n */\n connection: ConnectionAdapter\n\n /**\n * Initial messages to populate the chat\n */\n initialMessages?: Array<UIMessage<TTools>>\n\n /**\n * Unique identifier for this chat instance\n * Used for managing multiple chats\n */\n id?: string\n\n /**\n * Additional body parameters to send\n */\n body?: Record<string, any>\n\n /**\n * Callback when a response is received\n */\n onResponse?: (response?: Response) => void | Promise<void>\n\n /**\n * Callback when a stream chunk is received\n */\n onChunk?: (chunk: StreamChunk) => void\n\n /**\n * Callback when the response is finished\n */\n onFinish?: (message: UIMessage<TTools>) => void\n\n /**\n * Callback when an error occurs\n */\n onError?: (error: Error) => void\n\n /**\n * Callback when messages change\n */\n onMessagesChange?: (messages: Array<UIMessage<TTools>>) => void\n\n /**\n * Callback when loading state changes\n */\n onLoadingChange?: (isLoading: boolean) => void\n\n /**\n * Callback when error state changes\n */\n onErrorChange?: (error: Error | undefined) => void\n\n /**\n * Callback when chat status changes\n */\n onStatusChange?: (status: ChatClientState) => void\n\n /**\n * Client-side tools with execution logic\n * When provided, tools with execute functions will be called automatically\n */\n tools?: TTools\n\n /**\n * Stream processing options (optional)\n * Configure chunking strategy\n */\n streamProcessor?: {\n /**\n * Strategy for when to emit text updates\n * Defaults to ImmediateStrategy (every chunk)\n */\n chunkStrategy?: ChunkStrategy\n }\n}\n\nexport interface ChatRequestBody {\n messages: Array<ModelMessage>\n data?: Record<string, any>\n}\n\n/**\n * Create a typed array of client tools with proper type inference.\n * This eliminates the need for `as const` when defining tool arrays.\n *\n * @example\n * ```ts\n * const tools = clientTools(\n * myTool1.client(() => result1),\n * myTool2.client(() => result2),\n * )\n *\n * // tools is now properly typed as a tuple with literal tool names\n * // This enables type narrowing when checking part.name === 'toolName'\n * ```\n */\nexport function clientTools<const T extends Array<AnyClientTool>>(\n ...tools: T\n): T {\n return tools\n}\n\n/**\n * Helper to create typed chat client options\n * Use this to get proper type inference for messages\n *\n * @example\n * ```ts\n * const tools = clientTools(myTool1, myTool2)\n *\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools,\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * ```\n */\nexport function createChatClientOptions<\n const TTools extends ReadonlyArray<AnyClientTool>,\n>(options: ChatClientOptions<TTools>): ChatClientOptions<TTools> {\n return options\n}\n\n/**\n * Extract the message type from chat options\n *\n * @example\n * ```ts\n * const chatOptions = createChatClientOptions({\n * connection: fetchServerSentEvents('/api/chat'),\n * tools: [myTool1, myTool2],\n * })\n *\n * type MyMessages = InferChatMessages<typeof chatOptions>\n * // MyMessages is now Array<UIMessage<[typeof myTool1, typeof myTool2]>>\n * ```\n */\nexport type InferChatMessages<T> =\n T extends ChatClientOptions<infer TTools> ? Array<UIMessage<TTools>> : never\n"],"names":[],"mappings":"AAkPO,SAAS,eACX,OACA;AACH,SAAO;AACT;AAkBO,SAAS,wBAEd,SAA+D;AAC/D,SAAO;AACT;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-client",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-agnostic headless client for TanStack AI",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "src"
32
32
  ],
33
33
  "dependencies": {
34
- "@tanstack/ai": "0.2.2"
34
+ "@tanstack/ai": "0.3.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@vitest/coverage-v8": "4.0.14",
@@ -4,15 +4,16 @@ import {
4
4
  normalizeToUIMessage,
5
5
  } from '@tanstack/ai'
6
6
  import { DefaultChatClientEventEmitter } from './events'
7
+ import type { AnyClientTool, ModelMessage, StreamChunk } from '@tanstack/ai'
8
+ import type { ConnectionAdapter } from './connection-adapters'
9
+ import type { ChatClientEventEmitter } from './events'
7
10
  import type {
8
11
  ChatClientOptions,
12
+ ChatClientState,
9
13
  MessagePart,
10
14
  ToolCallPart,
11
15
  UIMessage,
12
16
  } from './types'
13
- import type { AnyClientTool, ModelMessage, StreamChunk } from '@tanstack/ai'
14
- import type { ConnectionAdapter } from './connection-adapters'
15
- import type { ChatClientEventEmitter } from './events'
16
17
 
17
18
  export class ChatClient {
18
19
  private processor: StreamProcessor
@@ -21,12 +22,17 @@ export class ChatClient {
21
22
  private body: Record<string, any> = {}
22
23
  private isLoading = false
23
24
  private error: Error | undefined = undefined
25
+ private status: ChatClientState = 'ready'
24
26
  private abortController: AbortController | null = null
25
27
  private events: ChatClientEventEmitter
26
28
  private clientToolsRef: { current: Map<string, AnyClientTool> }
27
29
  private currentStreamId: string | null = null
28
30
  private currentMessageId: string | null = null
29
31
  private postStreamActions: Array<() => Promise<void>> = []
32
+ // Track pending client tool executions to await them before stream finalization
33
+ private pendingToolExecutions: Map<string, Promise<void>> = new Map()
34
+ // Flag to deduplicate continuation checks during action draining
35
+ private continuationPending = false
30
36
 
31
37
  private callbacksRef: {
32
38
  current: {
@@ -37,6 +43,7 @@ export class ChatClient {
37
43
  onMessagesChange: (messages: Array<UIMessage>) => void
38
44
  onLoadingChange: (isLoading: boolean) => void
39
45
  onErrorChange: (error: Error | undefined) => void
46
+ onStatusChange: (status: ChatClientState) => void
40
47
  }
41
48
  }
42
49
 
@@ -63,6 +70,7 @@ export class ChatClient {
63
70
  onMessagesChange: options.onMessagesChange || (() => {}),
64
71
  onLoadingChange: options.onLoadingChange || (() => {}),
65
72
  onErrorChange: options.onErrorChange || (() => {}),
73
+ onStatusChange: options.onStatusChange || (() => {}),
66
74
  },
67
75
  }
68
76
 
@@ -75,13 +83,15 @@ export class ChatClient {
75
83
  this.callbacksRef.current.onMessagesChange(messages)
76
84
  },
77
85
  onStreamStart: () => {
78
- // Stream started
86
+ this.setStatus('streaming')
79
87
  },
80
88
  onStreamEnd: (message: UIMessage) => {
81
89
  this.callbacksRef.current.onFinish(message)
90
+ this.setStatus('ready')
82
91
  },
83
92
  onError: (error: Error) => {
84
93
  this.setError(error)
94
+ this.setStatus('error')
85
95
  this.callbacksRef.current.onError(error)
86
96
  },
87
97
  onTextUpdate: (messageId: string, content: string) => {
@@ -127,31 +137,41 @@ export class ChatClient {
127
137
  )
128
138
  }
129
139
  },
130
- onToolCall: async (args: {
140
+ onToolCall: (args: {
131
141
  toolCallId: string
132
142
  toolName: string
133
143
  input: any
134
144
  }) => {
135
145
  // Handle client-side tool execution automatically
136
146
  const clientTool = this.clientToolsRef.current.get(args.toolName)
137
- if (clientTool?.execute) {
138
- try {
139
- const output = await clientTool.execute(args.input)
140
- await this.addToolResult({
141
- toolCallId: args.toolCallId,
142
- tool: args.toolName,
143
- output,
144
- state: 'output-available',
145
- })
146
- } catch (error: any) {
147
- await this.addToolResult({
148
- toolCallId: args.toolCallId,
149
- tool: args.toolName,
150
- output: null,
151
- state: 'output-error',
152
- errorText: error.message,
153
- })
154
- }
147
+ const executeFunc = clientTool?.execute
148
+ if (executeFunc) {
149
+ // Create and track the execution promise
150
+ const executionPromise = (async () => {
151
+ try {
152
+ const output = await executeFunc(args.input)
153
+ await this.addToolResult({
154
+ toolCallId: args.toolCallId,
155
+ tool: args.toolName,
156
+ output,
157
+ state: 'output-available',
158
+ })
159
+ } catch (error: any) {
160
+ await this.addToolResult({
161
+ toolCallId: args.toolCallId,
162
+ tool: args.toolName,
163
+ output: null,
164
+ state: 'output-error',
165
+ errorText: error.message,
166
+ })
167
+ } finally {
168
+ // Remove from pending when complete
169
+ this.pendingToolExecutions.delete(args.toolCallId)
170
+ }
171
+ })()
172
+
173
+ // Track the pending execution
174
+ this.pendingToolExecutions.set(args.toolCallId, executionPromise)
155
175
  }
156
176
  },
157
177
  onApprovalRequest: (args: {
@@ -160,13 +180,16 @@ export class ChatClient {
160
180
  input: any
161
181
  approvalId: string
162
182
  }) => {
163
- this.events.approvalRequested(
164
- this.currentMessageId || '',
165
- args.toolCallId,
166
- args.toolName,
167
- args.input,
168
- args.approvalId,
169
- )
183
+ if (this.currentStreamId) {
184
+ this.events.approvalRequested(
185
+ this.currentStreamId,
186
+ this.currentMessageId || '',
187
+ args.toolCallId,
188
+ args.toolName,
189
+ args.input,
190
+ args.approvalId,
191
+ )
192
+ }
170
193
  },
171
194
  },
172
195
  })
@@ -184,6 +207,11 @@ export class ChatClient {
184
207
  this.events.loadingChanged(isLoading)
185
208
  }
186
209
 
210
+ private setStatus(status: ChatClientState): void {
211
+ this.status = status
212
+ this.callbacksRef.current.onStatusChange(status)
213
+ }
214
+
187
215
  private setError(error: Error | undefined): void {
188
216
  this.error = error
189
217
  this.callbacksRef.current.onErrorChange(error)
@@ -210,7 +238,10 @@ export class ChatClient {
210
238
  parts: [],
211
239
  createdAt: new Date(),
212
240
  }
213
- this.events.messageAppended(assistantMessage)
241
+ this.events.messageAppended(
242
+ assistantMessage,
243
+ this.currentStreamId || undefined,
244
+ )
214
245
 
215
246
  // Process each chunk
216
247
  for await (const chunk of source) {
@@ -221,6 +252,12 @@ export class ChatClient {
221
252
  await new Promise((resolve) => setTimeout(resolve, 0))
222
253
  }
223
254
 
255
+ // Wait for all pending tool executions to complete before finalizing
256
+ // This ensures client tools finish before we check for continuation
257
+ if (this.pendingToolExecutions.size > 0) {
258
+ await Promise.all(this.pendingToolExecutions.values())
259
+ }
260
+
224
261
  // Finalize the stream
225
262
  this.processor.finalizeStream()
226
263
 
@@ -281,6 +318,12 @@ export class ChatClient {
281
318
  const messages = this.processor.getMessages()
282
319
  this.processor.setMessages([...messages, uiMessage])
283
320
 
321
+ // If stream is in progress, queue the response for after it ends
322
+ if (this.isLoading) {
323
+ this.queuePostStreamAction(() => this.streamResponse())
324
+ return
325
+ }
326
+
284
327
  await this.streamResponse()
285
328
  }
286
329
 
@@ -288,13 +331,22 @@ export class ChatClient {
288
331
  * Stream a response from the LLM
289
332
  */
290
333
  private async streamResponse(): Promise<void> {
334
+ // Guard against concurrent streams - if already loading, skip
335
+ if (this.isLoading) {
336
+ return
337
+ }
338
+
291
339
  this.setIsLoading(true)
340
+ this.setStatus('submitted')
292
341
  this.setError(undefined)
293
342
  this.abortController = new AbortController()
343
+ // Reset pending tool executions for the new stream
344
+ this.pendingToolExecutions.clear()
345
+ let streamCompletedSuccessfully = false
294
346
 
295
347
  try {
296
- // Get model messages for the LLM
297
- const modelMessages = this.processor.toModelMessages()
348
+ // Get UIMessages with parts (preserves approval state and client tool results)
349
+ const messages = this.processor.getMessages()
298
350
 
299
351
  // Call onResponse callback
300
352
  await this.callbacksRef.current.onResponse()
@@ -307,18 +359,20 @@ export class ChatClient {
307
359
 
308
360
  // Connect and stream
309
361
  const stream = this.connection.connect(
310
- modelMessages,
362
+ messages,
311
363
  bodyWithConversationId,
312
364
  this.abortController.signal,
313
365
  )
314
366
 
315
367
  await this.processStream(stream)
368
+ streamCompletedSuccessfully = true
316
369
  } catch (err) {
317
370
  if (err instanceof Error) {
318
371
  if (err.name === 'AbortError') {
319
372
  return
320
373
  }
321
374
  this.setError(err)
375
+ this.setStatus('error')
322
376
  this.callbacksRef.current.onError(err)
323
377
  }
324
378
  } finally {
@@ -327,6 +381,20 @@ export class ChatClient {
327
381
 
328
382
  // Drain any actions that were queued while the stream was in progress
329
383
  await this.drainPostStreamActions()
384
+
385
+ // Continue conversation if the stream ended with a tool result (server tool completed)
386
+ if (streamCompletedSuccessfully) {
387
+ const messages = this.processor.getMessages()
388
+ const lastPart = messages.at(-1)?.parts.at(-1)
389
+
390
+ if (lastPart?.type === 'tool-result' && this.shouldAutoSend()) {
391
+ try {
392
+ await this.checkForContinuation()
393
+ } catch (error) {
394
+ console.error('Failed to continue flow after tool result:', error)
395
+ }
396
+ }
397
+ }
330
398
  }
331
399
  }
332
400
 
@@ -362,6 +430,7 @@ export class ChatClient {
362
430
  this.abortController = null
363
431
  }
364
432
  this.setIsLoading(false)
433
+ this.setStatus('ready')
365
434
  this.events.stopped()
366
435
  }
367
436
 
@@ -470,8 +539,18 @@ export class ChatClient {
470
539
  * Check if we should continue the flow and do so if needed
471
540
  */
472
541
  private async checkForContinuation(): Promise<void> {
542
+ // Prevent duplicate continuation attempts
543
+ if (this.continuationPending || this.isLoading) {
544
+ return
545
+ }
546
+
473
547
  if (this.shouldAutoSend()) {
474
- await this.streamResponse()
548
+ this.continuationPending = true
549
+ try {
550
+ await this.streamResponse()
551
+ } finally {
552
+ this.continuationPending = false
553
+ }
475
554
  }
476
555
  }
477
556
 
@@ -496,6 +575,13 @@ export class ChatClient {
496
575
  return this.isLoading
497
576
  }
498
577
 
578
+ /**
579
+ * Get current status
580
+ */
581
+ getStatus(): ChatClientState {
582
+ return this.status
583
+ }
584
+
499
585
  /**
500
586
  * Get current error
501
587
  */