@@ -233,6 +233,152 @@ describe('tool validation error handling', () => {
233233 expect ( errorEvents . length ) . toBe ( 0 )
234234 } )
235235
236+ it ( 'should parse input JSON string from AI SDK before validation' , async ( ) => {
237+ // The AI SDK can emit tool-call chunks with `input` as a raw JSON string
238+ // when upstream schema validation fails and the repair function returns
239+ // the original tool call unchanged. The stream parser should parse the
240+ // string into an object before handing it to the tool executor.
241+ const agentWithReadFiles : AgentTemplate = {
242+ ...testAgentTemplate ,
243+ toolNames : [ 'read_files' , 'end_turn' ] ,
244+ }
245+
246+ const stringInputToolCallChunk = {
247+ type : 'tool-call' as const ,
248+ toolName : 'read_files' ,
249+ toolCallId : 'string-input-tool-call-id' ,
250+ input : JSON . stringify ( { paths : [ 'test.ts' ] } ) as any ,
251+ }
252+
253+ async function * mockStream ( ) {
254+ yield stringInputToolCallChunk
255+ return promptSuccess ( 'mock-message-id' )
256+ }
257+
258+ const sessionState = getInitialSessionState ( mockFileContext )
259+ const agentState = sessionState . mainAgentState
260+
261+ agentRuntimeImpl . requestFiles = async ( ) => ( {
262+ 'test.ts' : 'console.log("test")' ,
263+ } )
264+
265+ const responseChunks : ( string | PrintModeEvent ) [ ] = [ ]
266+
267+ await processStream ( {
268+ ...agentRuntimeImpl ,
269+ agentContext : { } ,
270+ agentState,
271+ agentStepId : 'test-step-id' ,
272+ agentTemplate : agentWithReadFiles ,
273+ ancestorRunIds : [ ] ,
274+ clientSessionId : 'test-session' ,
275+ fileContext : mockFileContext ,
276+ fingerprintId : 'test-fingerprint' ,
277+ fullResponse : '' ,
278+ localAgentTemplates : { 'test-agent' : agentWithReadFiles } ,
279+ messages : [ ] ,
280+ prompt : 'test prompt' ,
281+ repoId : undefined ,
282+ repoUrl : undefined ,
283+ runId : 'test-run-id' ,
284+ signal : new AbortController ( ) . signal ,
285+ stream : mockStream ( ) ,
286+ system : 'test system' ,
287+ tools : { } ,
288+ userId : 'test-user' ,
289+ userInputId : 'test-input-id' ,
290+ onCostCalculated : async ( ) => { } ,
291+ onResponseChunk : ( chunk ) => {
292+ responseChunks . push ( chunk )
293+ } ,
294+ } )
295+
296+ const toolCallEvents = responseChunks . filter (
297+ ( chunk ) : chunk is Extract < PrintModeEvent , { type : 'tool_call' } > =>
298+ typeof chunk !== 'string' && chunk . type === 'tool_call' ,
299+ )
300+ expect ( toolCallEvents . length ) . toBe ( 1 )
301+ expect ( toolCallEvents [ 0 ] . toolName ) . toBe ( 'read_files' )
302+ expect ( toolCallEvents [ 0 ] . input ) . toEqual ( { paths : [ 'test.ts' ] } )
303+
304+ const errorEvents = responseChunks . filter (
305+ ( chunk ) : chunk is Extract < PrintModeEvent , { type : 'error' } > =>
306+ typeof chunk !== 'string' && chunk . type === 'error' ,
307+ )
308+ expect ( errorEvents . length ) . toBe ( 0 )
309+ } )
310+
311+ it ( 'should emit a clear error when tool input is an unparseable string' , async ( ) => {
312+ const agentWithReadFiles : AgentTemplate = {
313+ ...testAgentTemplate ,
314+ toolNames : [ 'read_files' , 'end_turn' ] ,
315+ }
316+
317+ const invalidStringToolCallChunk = {
318+ type : 'tool-call' as const ,
319+ toolName : 'read_files' ,
320+ toolCallId : 'invalid-string-tool-call-id' ,
321+ input : '{"paths": ["test.ts"' as any , // truncated/malformed JSON
322+ }
323+
324+ async function * mockStream ( ) {
325+ yield invalidStringToolCallChunk
326+ return promptSuccess ( 'mock-message-id' )
327+ }
328+
329+ const sessionState = getInitialSessionState ( mockFileContext )
330+ const agentState = sessionState . mainAgentState
331+
332+ const responseChunks : ( string | PrintModeEvent ) [ ] = [ ]
333+
334+ const result = await processStream ( {
335+ ...agentRuntimeImpl ,
336+ agentContext : { } ,
337+ agentState,
338+ agentStepId : 'test-step-id' ,
339+ agentTemplate : agentWithReadFiles ,
340+ ancestorRunIds : [ ] ,
341+ clientSessionId : 'test-session' ,
342+ fileContext : mockFileContext ,
343+ fingerprintId : 'test-fingerprint' ,
344+ fullResponse : '' ,
345+ localAgentTemplates : { 'test-agent' : agentWithReadFiles } ,
346+ messages : [ ] ,
347+ prompt : 'test prompt' ,
348+ repoId : undefined ,
349+ repoUrl : undefined ,
350+ runId : 'test-run-id' ,
351+ signal : new AbortController ( ) . signal ,
352+ stream : mockStream ( ) ,
353+ system : 'test system' ,
354+ tools : { } ,
355+ userId : 'test-user' ,
356+ userInputId : 'test-input-id' ,
357+ onCostCalculated : async ( ) => { } ,
358+ onResponseChunk : ( chunk ) => {
359+ responseChunks . push ( chunk )
360+ } ,
361+ } )
362+
363+ const errorEvents = responseChunks . filter (
364+ ( chunk ) : chunk is Extract < PrintModeEvent , { type : 'error' } > =>
365+ typeof chunk !== 'string' && chunk . type === 'error' ,
366+ )
367+ expect ( errorEvents . length ) . toBe ( 1 )
368+ expect ( errorEvents [ 0 ] . message ) . toContain (
369+ 'tool arguments were a string, not a JSON object' ,
370+ )
371+ expect ( errorEvents [ 0 ] . message ) . toContain ( 'Original tool call input:' )
372+
373+ expect ( result . hadToolCallError ) . toBe ( true )
374+
375+ const toolCallEvents = responseChunks . filter (
376+ ( chunk ) : chunk is Extract < PrintModeEvent , { type : 'tool_call' } > =>
377+ typeof chunk !== 'string' && chunk . type === 'tool_call' ,
378+ )
379+ expect ( toolCallEvents . length ) . toBe ( 0 )
380+ } )
381+
236382 it ( 'should preserve tool_call/tool_result ordering when custom tool setup is async' , async ( ) => {
237383 const toolName = 'delayed_custom_tool'
238384 const agentWithCustomTool : AgentTemplate = {
0 commit comments