@@ -3,6 +3,7 @@ import type { AiBuilderMessage, AiBuilderProposal } from "./agentChat";
33import { normalizeAiProposal } from "./agentChatProposal" ;
44
55type JsonObject = Record < string , unknown > ;
6+ type ToolEvent = Extract < ChatStreamEvent , { type : "tool_started" | "tool_finished" } > ;
67
78export type ChatStreamEvent =
89 | { type : "run_started" ; model ?: string }
@@ -167,6 +168,36 @@ function createToolCallId(toolName: string, toolCallId?: string): string {
167168 return typeof toolCallId === "string" && toolCallId . trim ( ) . length > 0 ? toolCallId : `${ toolName } -${ Date . now ( ) } ` ;
168169}
169170
171+ function collapseWithFinishedEvent (
172+ event : ToolEvent ,
173+ pendingEvents : ToolEvent [ ] ,
174+ ) : { effectiveEvent : ToolEvent ; wasCollapsed : boolean } {
175+ if ( event . type !== "tool_started" ) {
176+ return { effectiveEvent : event , wasCollapsed : false } ;
177+ }
178+
179+ const toolName = typeof event . tool_name === "string" ? event . tool_name : "unknown" ;
180+ const hasExplicitCallId = typeof event . tool_call_id === "string" && event . tool_call_id . trim ( ) . length > 0 ;
181+
182+ const finishedIdx = pendingEvents . findIndex ( ( e ) => {
183+ if ( e . type !== "tool_finished" ) {
184+ return false ;
185+ }
186+ const eName = typeof e . tool_name === "string" ? e . tool_name : "unknown" ;
187+ const eHasId = typeof e . tool_call_id === "string" && e . tool_call_id . trim ( ) . length > 0 ;
188+ if ( hasExplicitCallId || eHasId ) {
189+ return hasExplicitCallId && eHasId && e . tool_call_id === event . tool_call_id ;
190+ }
191+ return eName === toolName ;
192+ } ) ;
193+
194+ if ( finishedIdx >= 0 ) {
195+ return { effectiveEvent : pendingEvents . splice ( finishedIdx , 1 ) [ 0 ] , wasCollapsed : true } ;
196+ }
197+
198+ return { effectiveEvent : event , wasCollapsed : false } ;
199+ }
200+
170201function createAssistantStreamController ( {
171202 assistantMessageId,
172203 insertAiMessageBefore,
@@ -181,6 +212,9 @@ function createAssistantStreamController({
181212 let assistantContentSnapshot = "" ;
182213 let pendingRenderBuffer = "" ;
183214 let isRenderLoopRunning = false ;
215+ const pendingToolEvents : ToolEvent [ ] = [ ] ;
216+ let isToolLoopRunning = false ;
217+ const flushedToolCallIds = new Set < string > ( ) ;
184218
185219 const flushPendingRenderBuffer = async ( ) => {
186220 if ( isRenderLoopRunning ) {
@@ -214,36 +248,74 @@ function createAssistantStreamController({
214248 void flushPendingRenderBuffer ( ) ;
215249 } ;
216250
217- const upsertToolMessage = ( event : Extract < ChatStreamEvent , { type : "tool_started" | "tool_finished" } > ) => {
251+ const applyToolEvent = ( event : ToolEvent ) : boolean => {
218252 const toolName = typeof event . tool_name === "string" ? event . tool_name : "unknown" ;
253+ const hasExplicitCallId = typeof event . tool_call_id === "string" && event . tool_call_id . trim ( ) . length > 0 ;
219254 const toolCallId = createToolCallId ( toolName , event . tool_call_id ) ;
220255 const toolLabel = typeof event . tool_label === "string" ? event . tool_label . trim ( ) : "" ;
221256 const content = event . type === "tool_started" ? `${ toolLabel } ...` : toolLabel ;
222257 const toolStatus = event . type === "tool_started" ? "running" : "completed" ;
223258
259+ const isAlreadyTracked = flushedToolCallIds . has ( toolCallId ) ;
260+ const isNameBasedUpdate = ! isAlreadyTracked && event . type === "tool_finished" && ! hasExplicitCallId ;
261+ const isNewInsertion = ! isAlreadyTracked && ! isNameBasedUpdate ;
262+
263+ flushedToolCallIds . add ( toolCallId ) ;
264+
224265 setAiMessages ( ( previous ) => {
225- const existingIndex = previous . findIndex (
226- ( message ) => message . role === "tool" && message . toolCallId === toolCallId ,
227- ) ;
228- const nextMessage : AiBuilderMessage = {
229- id : existingIndex >= 0 ? previous [ existingIndex ] . id : `tool-${ toolCallId } ` ,
230- role : "tool" ,
231- content,
232- toolCallId,
233- toolStatus,
234- } ;
266+ let existingIndex = previous . findIndex ( ( message ) => message . role === "tool" && message . toolCallId === toolCallId ) ;
267+
268+ if ( existingIndex < 0 && event . type === "tool_finished" && ! hasExplicitCallId && toolLabel . length > 0 ) {
269+ existingIndex = previous . findIndex (
270+ ( message ) =>
271+ message . role === "tool" && message . toolStatus === "running" && message . content . startsWith ( toolLabel ) ,
272+ ) ;
273+ }
274+
235275 if ( existingIndex >= 0 ) {
236276 const updated = [ ...previous ] ;
237- updated [ existingIndex ] = nextMessage ;
277+ updated [ existingIndex ] = { ... previous [ existingIndex ] , content , toolStatus } ;
238278 return trimAiMessages ( updated ) ;
239279 }
240280
241- return insertAiMessageBefore ( previous , nextMessage , assistantMessageId ) ;
281+ return insertAiMessageBefore (
282+ previous ,
283+ { id : `tool-${ toolCallId } ` , role : "tool" , content, toolCallId, toolStatus } ,
284+ assistantMessageId ,
285+ ) ;
242286 } ) ;
287+
288+ return isNewInsertion ;
289+ } ;
290+
291+ const flushPendingToolEvents = async ( ) => {
292+ if ( isToolLoopRunning ) {
293+ return ;
294+ }
295+
296+ isToolLoopRunning = true ;
297+ try {
298+ while ( pendingToolEvents . length > 0 ) {
299+ const event = pendingToolEvents . shift ( ) ! ;
300+ const { effectiveEvent, wasCollapsed } = collapseWithFinishedEvent ( event , pendingToolEvents ) ;
301+ const isNewInsertion = applyToolEvent ( effectiveEvent ) ;
302+
303+ if ( isNewInsertion || wasCollapsed ) {
304+ await sleep ( 150 ) ;
305+ }
306+ }
307+ } finally {
308+ isToolLoopRunning = false ;
309+ }
310+ } ;
311+
312+ const upsertToolMessage = ( event : ToolEvent ) => {
313+ pendingToolEvents . push ( event ) ;
314+ void flushPendingToolEvents ( ) ;
243315 } ;
244316
245317 const waitForRenderLoopIdle = async ( ) => {
246- while ( isRenderLoopRunning || pendingRenderBuffer . length > 0 ) {
318+ while ( isRenderLoopRunning || pendingRenderBuffer . length > 0 || isToolLoopRunning || pendingToolEvents . length > 0 ) {
247319 await sleep ( 10 ) ;
248320 }
249321 } ;
0 commit comments