Skip to content

Commit 99fe2c2

Browse files
committed
fix(cli): improve main agent text streaming with buffered tool call
filtering Prevents <codebuff_tool_call> blocks from appearing in the UI by buffering and processing text chunks to detect and skip tool call boundaries. Adds intelligent buffer management that outputs safe text while keeping a 50-char buffer to avoid partial tags. Ensures all legitimate text content is displayed by flushing remaining buffer at stream end. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 3819fc9 commit 99fe2c2

File tree

1 file changed

+68
-113
lines changed

1 file changed

+68
-113
lines changed

cli/src/hooks/use-send-message.ts

Lines changed: 68 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,56 @@ const updateBlocksRecursively = (
4545
})
4646
}
4747

48+
// Helper function to process buffered text and filter out tool calls
49+
const processToolCallBuffer = (
50+
bufferState: { buffer: string; insideToolCall: boolean },
51+
onTextOutput: (text: string) => void,
52+
) => {
53+
let processed = false
54+
55+
if (
56+
!bufferState.insideToolCall &&
57+
bufferState.buffer.includes('<codebuff_tool_call>')
58+
) {
59+
const openTagIndex = bufferState.buffer.indexOf('<codebuff_tool_call>')
60+
const text = bufferState.buffer.substring(0, openTagIndex)
61+
if (text) {
62+
onTextOutput(text)
63+
}
64+
bufferState.insideToolCall = true
65+
bufferState.buffer = bufferState.buffer.substring(
66+
openTagIndex + '<codebuff_tool_call>'.length,
67+
)
68+
processed = true
69+
} else if (
70+
bufferState.insideToolCall &&
71+
bufferState.buffer.includes('</codebuff_tool_call>')
72+
) {
73+
const closeTagIndex = bufferState.buffer.indexOf('</codebuff_tool_call>')
74+
bufferState.insideToolCall = false
75+
bufferState.buffer = bufferState.buffer.substring(
76+
closeTagIndex + '</codebuff_tool_call>'.length,
77+
)
78+
processed = true
79+
} else if (!bufferState.insideToolCall && bufferState.buffer.length > 25) {
80+
// Output safe text, keeping last 25 chars in buffer (enough to buffer <codebuff_tool_call>)
81+
const safeToOutput = bufferState.buffer.substring(
82+
0,
83+
bufferState.buffer.length - 25,
84+
)
85+
if (safeToOutput) {
86+
onTextOutput(safeToOutput)
87+
}
88+
bufferState.buffer = bufferState.buffer.substring(
89+
bufferState.buffer.length - 25,
90+
)
91+
}
92+
93+
if (processed) {
94+
processToolCallBuffer(bufferState, onTextOutput)
95+
}
96+
}
97+
4898
interface UseSendMessageOptions {
4999
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
50100
setFocusedAgentId: (id: string | null) => void
@@ -404,57 +454,8 @@ export const useSendMessage = ({
404454
signal: abortController.signal,
405455

406456
handleStreamChunk: (chunk: any) => {
407-
const keys = Object.keys(chunk)
408-
.filter((k) => !isNaN(Number(k)))
409-
.sort((a, b) => Number(a) - Number(b))
410-
let text = keys.map((k) => chunk[k]).join('')
411-
412-
text = text.replace(
413-
/<codebuff_tool_call>[\s\S]*?<\/codebuff_tool_call>/g,
414-
'',
415-
)
416-
417-
if (!text) return
418-
419-
if (!hasReceivedContent) {
420-
hasReceivedContent = true
421-
setIsWaitingForResponse(false)
422-
}
423-
424-
logger.info('setMessages: handleStreamChunk (main agent text)', {
425-
text,
426-
})
427-
queueMessageUpdate((prev) =>
428-
prev.map((msg) => {
429-
if (msg.id !== aiMessageId) {
430-
return msg
431-
}
432-
433-
const blocks: ContentBlock[] = msg.blocks ? [...msg.blocks] : []
434-
const lastBlock = blocks[blocks.length - 1]
435-
436-
if (lastBlock && lastBlock.type === 'text') {
437-
const newContent = lastBlock.content + text
438-
const updatedTextBlock: ContentBlock = {
439-
type: 'text',
440-
content: newContent,
441-
}
442-
return {
443-
...msg,
444-
blocks: [...blocks.slice(0, -1), updatedTextBlock],
445-
}
446-
}
447-
448-
const newTextBlock: ContentBlock = {
449-
type: 'text',
450-
content: text,
451-
}
452-
return {
453-
...msg,
454-
blocks: [...blocks, newTextBlock],
455-
}
456-
}),
457-
)
457+
// Streaming chunks are also sent via text events, so we ignore them here to avoid duplication
458+
// Text events have better handling for tool call filtering
458459
},
459460

460461
handleEvent: (event: any) => {
@@ -471,59 +472,9 @@ export const useSendMessage = ({
471472

472473
bufferState.buffer += chunk
473474

474-
const processBuffer = () => {
475-
let processed = false
476-
if (
477-
!bufferState.insideToolCall &&
478-
bufferState.buffer.includes('<codebuff_tool_call>')
479-
) {
480-
const openTagIndex = bufferState.buffer.indexOf(
481-
'<codebuff_tool_call>',
482-
)
483-
const text = bufferState.buffer.substring(0, openTagIndex)
484-
if (text) {
485-
updateAgentContent(agentId, { type: 'text', content: text })
486-
}
487-
bufferState.insideToolCall = true
488-
bufferState.buffer = bufferState.buffer.substring(
489-
openTagIndex + '<codebuff_tool_call>'.length,
490-
)
491-
processed = true
492-
} else if (
493-
bufferState.insideToolCall &&
494-
bufferState.buffer.includes('</codebuff_tool_call>')
495-
) {
496-
const closeTagIndex = bufferState.buffer.indexOf(
497-
'</codebuff_tool_call>',
498-
)
499-
// Skip the tool call content - we'll handle it via tool_call event
500-
bufferState.insideToolCall = false
501-
bufferState.buffer = bufferState.buffer.substring(
502-
closeTagIndex + '</codebuff_tool_call>'.length,
503-
)
504-
processed = true
505-
} else if (
506-
!bufferState.insideToolCall &&
507-
bufferState.buffer.length > 50
508-
) {
509-
const safeToOutput = bufferState.buffer.substring(
510-
0,
511-
bufferState.buffer.length - 50,
512-
)
513-
updateAgentContent(agentId, {
514-
type: 'text',
515-
content: safeToOutput,
516-
})
517-
bufferState.buffer = bufferState.buffer.substring(
518-
bufferState.buffer.length - 50,
519-
)
520-
}
521-
522-
if (processed) {
523-
processBuffer()
524-
}
525-
}
526-
processBuffer()
475+
processToolCallBuffer(bufferState, (text) => {
476+
updateAgentContent(agentId, { type: 'text', content: text })
477+
})
527478
return
528479
}
529480

@@ -533,15 +484,13 @@ export const useSendMessage = ({
533484
'',
534485
)
535486

536-
if (text.includes('<codebuff_tool_call>')) {
537-
logger.warn('Tool XML detected in text event post-filter', {
538-
agentId: event.agentId ?? 'root',
539-
textPreview: text.slice(0, 80),
540-
})
541-
}
542-
543487
if (!text) return
544488

489+
if (!hasReceivedContent) {
490+
hasReceivedContent = true
491+
setIsWaitingForResponse(false)
492+
}
493+
545494
if (event.agentId) {
546495
logger.info('setMessages: text event with agentId', {
547496
agentId: event.agentId,
@@ -551,7 +500,6 @@ export const useSendMessage = ({
551500
type: 'text',
552501
content: text,
553502
})
554-
return
555503
} else {
556504
logger.info('setMessages: text event without agentId', {
557505
textPreview: text.slice(0, 100),
@@ -567,7 +515,14 @@ export const useSendMessage = ({
567515
: []
568516
const lastBlock = blocks[blocks.length - 1]
569517

518+
// Deduplicate: if the new text is already at the end of the last block, skip it
570519
if (lastBlock && lastBlock.type === 'text') {
520+
if (lastBlock.content.endsWith(text)) {
521+
logger.info('Skipping duplicate main agent text', {
522+
textPreview: text.slice(0, 100),
523+
})
524+
return msg
525+
}
571526
const updatedTextBlock: ContentBlock = {
572527
type: 'text',
573528
content: lastBlock.content + text,
@@ -588,8 +543,8 @@ export const useSendMessage = ({
588543
}
589544
}),
590545
)
591-
return
592546
}
547+
return
593548
}
594549

595550
if (event.type === 'finish' && event.totalCost !== undefined) {

0 commit comments

Comments
 (0)