@@ -54,6 +54,100 @@ function isValidToolName(name: string): boolean {
5454 return VALID_TOOL_NAMES . has ( lower ) || lower in TOOL_ALIASES ;
5555}
5656
57+ /**
58+ * Strip function-call style tool invocations from streaming text.
59+ * Models sometimes output: write_file("path", "content") or write_file("path", """content""")
60+ * These are detected by the backend after generation completes, but appear naked in the UI
61+ * during streaming. This function strips them so they don't clutter the chat.
62+ */
63+ export function stripFunctionCallTools ( text : string ) : string {
64+ // Match: toolname("arg1", "arg2...") or toolname('arg1', 'arg2...')
65+ // Handles multi-line content with """/''' and regular "/'.
66+ // This is a greedy strip — once we see tool_name( we consume until the matching ) or end of text
67+ const TOOL_NAMES = [ 'write_file' , 'read_file' , 'edit_file' , 'delete_file' , 'run_command' , 'list_directory' , 'find_files' ] ;
68+ let result = text ;
69+ for ( const tool of TOOL_NAMES ) {
70+ // Pattern: tool_name( followed by quoted args — consume everything until balanced )
71+ const startPattern = new RegExp ( `\\b${ tool } \\s*\\(\\s*['"\`]` , 'gi' ) ;
72+ let match ;
73+ while ( ( match = startPattern . exec ( result ) ) !== null ) {
74+ // Find the matching closing paren by counting parens (accounting for strings)
75+ let depth = 1 ;
76+ let j = match . index + match [ 0 ] . length ;
77+ let inString = true ; // We started inside the first string quote
78+ let stringChar = match [ 0 ] . slice ( - 1 ) ; // The quote char that opened the string
79+ let escaped = false ;
80+ let tripleQuote = false ;
81+
82+ // Check for triple-quote
83+ if ( j < result . length && result [ j ] === stringChar && j + 1 < result . length && result [ j + 1 ] === stringChar ) {
84+ tripleQuote = true ;
85+ j += 2 ; // Skip the other two quotes
86+ }
87+
88+ while ( j < result . length && depth > 0 ) {
89+ const ch = result [ j ] ;
90+ if ( escaped ) {
91+ escaped = false ;
92+ j ++ ;
93+ continue ;
94+ }
95+ if ( ch === '\\' && inString ) {
96+ escaped = true ;
97+ j ++ ;
98+ continue ;
99+ }
100+ if ( inString ) {
101+ if ( tripleQuote ) {
102+ // Look for closing triple-quote
103+ if ( ch === stringChar && j + 2 < result . length && result [ j + 1 ] === stringChar && result [ j + 2 ] === stringChar ) {
104+ inString = false ;
105+ tripleQuote = false ;
106+ j += 3 ;
107+ continue ;
108+ }
109+ } else {
110+ if ( ch === stringChar ) {
111+ inString = false ;
112+ }
113+ }
114+ j ++ ;
115+ continue ;
116+ }
117+ // Not in string
118+ if ( ch === "'" || ch === '"' || ch === '`' ) {
119+ inString = true ;
120+ stringChar = ch ;
121+ // Check for triple-quote
122+ if ( j + 2 < result . length && result [ j + 1 ] === ch && result [ j + 2 ] === ch ) {
123+ tripleQuote = true ;
124+ j += 3 ;
125+ continue ;
126+ }
127+ } else if ( ch === '(' ) {
128+ depth ++ ;
129+ } else if ( ch === ')' ) {
130+ depth -- ;
131+ }
132+ j ++ ;
133+ }
134+
135+ if ( depth === 0 ) {
136+ // Found complete function call — strip it
137+ const before = result . slice ( 0 , match . index ) . trimEnd ( ) ;
138+ const after = result . slice ( j ) . trimStart ( ) ;
139+ result = before + ( before && after ? '\n' : '' ) + after ;
140+ startPattern . lastIndex = 0 ; // Reset to search from beginning
141+ } else {
142+ // Incomplete function call (still streaming) — strip from start to end
143+ result = result . slice ( 0 , match . index ) . trimEnd ( ) ;
144+ break ;
145+ }
146+ }
147+ }
148+ return result ;
149+ }
150+
57151/**
58152 * Strip tool execution result sections, orphan headers, and internal reasoning from text.
59153 */
@@ -62,6 +156,13 @@ export function stripToolArtifacts(text: string): string {
62156 // Remove <think>/<thinking> blocks that weren't caught earlier
63157 cleaned = cleaned . replace ( / < t h i n k (?: i n g ) ? > \s * [ \s \S ] * ?< \/ t h i n k (?: i n g ) ? > / gi, '' ) ;
64158 cleaned = cleaned . replace ( / < \/ ? t h i n k (?: i n g ) ? > / gi, '' ) ;
159+ // Strip function-call style tool invocations (write_file("path", "content") etc.)
160+ cleaned = stripFunctionCallTools ( cleaned ) ;
161+ // Strip naked JSON tool calls that couldn't be parsed by splitInlineToolCalls
162+ // (e.g., malformed JSON with literal newlines inside strings). These are already
163+ // handled by the backend after generation completes; we just hide them during streaming.
164+ // The pattern matches {"tool":"..." or {"name":"..." followed by any content until } or end.
165+ cleaned = cleaned . replace ( / \{ \s * " (?: t o o l | n a m e ) " \s * : \s * " [ ^ " ] * " [ \s \S ] * ?(?: \} (?: \s * \} ) ? | $ ) / g, '' ) ;
65166 // (model output filters removed — model text is shown verbatim)
66167 // Strip bare code-fence language labels leaked by the model without backtick fences
67168 // e.g. the model outputs `json\n{"tool":...}` instead of ```json\n{...}\n```. These labels
@@ -175,36 +276,28 @@ export function splitInlineToolCalls(text: string): ContentSegment[] {
175276 jsonRegex . lastIndex = lastIndex ;
176277 }
177278 } catch {
178- // Malformed JSON (e.g. unescaped HTML inside content param) — skip the entire
179- // blob so it doesn't leak into "remaining text" and appear as raw JSON in chat.
180- // When HTML/CSS content confuses the brace counter (unescaped quotes make inString
181- // tracking lose sync), endIdx may be set too early. Any text after endIdx that is
182- // still part of the blob would leak as raw content. For tool-call blobs we extend
183- // the skip window: find the furthest plausible closing braces after endIdx.
279+ // Malformed JSON (e.g. literal newlines inside strings, unescaped quotes) — skip
280+ // the entire blob so it doesn't leak as naked text. The backend will parse and
281+ // execute tool calls properly after generation completes; we just need to hide
282+ // the raw JSON during streaming.
184283 if ( startIdx > lastIndex ) {
185284 const before = stripToolArtifacts ( text . substring ( lastIndex , startIdx ) ) . replace ( / \[ \s * $ / , '' ) . trim ( ) ;
186285 if ( before ) results . push ( { type : 'text' , content : before } ) ;
187286 }
188- let skipEnd = endIdx ;
189- // If the blob looked like a tool call, extend past any trailing content that
190- // was likely part of the same JSON blob (e.g., leaked HTML/CSS after premature close)
191- const blobHead = text . substring ( startIdx , Math . min ( startIdx + 200 , text . length ) ) ;
192- if ( / " (?: t o o l | n a m e ) " \s * : \s * " / . test ( blobHead ) ) {
193- // Look for the next clearly non-JSON content boundary: a line starting with
194- // a letter/header/markdown that isn't part of code content, or end of text
195- const afterBlob = text . substring ( endIdx ) ;
196- // Find the next double-newline paragraph break — content before it is likely
197- // leaked code from the blob, content after it is likely the model's prose response.
198- const paraBreak = afterBlob . indexOf ( '\n\n' ) ;
199- if ( paraBreak > 0 ) {
200- // Only extend to the paragraph break — preserve everything after it
201- skipEnd = endIdx + paraBreak ;
202- }
203- // If no paragraph break found, DON'T consume to end — the brace counter's endIdx
204- // is the best we have. Some content may leak, but that's better than swallowing
205- // the model's actual follow-up prose.
287+ // Mark this as a tool segment even though we couldn't parse it — this prevents
288+ // the JSON from rendering as text. The tool bubble from llm-tool-generating IPC
289+ // already shows the user what tool is being called.
290+ const toolNameMatch = match [ 0 ] . match ( / " (?: t o o l | n a m e ) " \s * : \s * " ( [ ^ " ] * ) " / ) ;
291+ const toolName = toolNameMatch ? toolNameMatch [ 1 ] : 'unknown' ;
292+ if ( isValidToolName ( toolName ) ) {
293+ // Consume to end of text — the malformed JSON likely continues to the end
294+ // (model is still streaming the content param). Backend handles actual execution.
295+ results . push ( { type : 'tool' , content : text . substring ( startIdx ) , toolCall : { tool : toolName , params : { } } } ) ;
296+ lastIndex = text . length ;
297+ break ;
206298 }
207- lastIndex = skipEnd ;
299+ // Unknown tool name — skip past the detected JSON blob boundaries
300+ lastIndex = endIdx ;
208301 jsonRegex . lastIndex = lastIndex ;
209302 }
210303 }
0 commit comments