1- import { askFollowupQuestionTool } from "../AskFollowupQuestionTool"
1+ import { askFollowupQuestionTool , coerceFollowUp } from "../AskFollowupQuestionTool"
22import { ToolUse } from "../../../shared/tools"
33import { NativeToolCallParser } from "../../assistant-message/NativeToolCallParser"
44
@@ -166,7 +166,7 @@ describe("askFollowupQuestionTool", () => {
166166 expect ( mockCline . ask ) . not . toHaveBeenCalled ( )
167167 } )
168168
169- it ( "should handle non-array follow_up parameter " , async ( ) => {
169+ it ( "should coerce a plain string follow_up into a single-item array " , async ( ) => {
170170 const block : ToolUse = {
171171 type : "tool_use" ,
172172 name : "ask_followup_question" ,
@@ -186,14 +186,104 @@ describe("askFollowupQuestionTool", () => {
186186 pushToolResult : mockPushToolResult ,
187187 } )
188188
189+ // Plain string should be coerced to [{ text: "not an array" }]
190+ expect ( mockCline . ask ) . toHaveBeenCalledWith (
191+ "followup" ,
192+ expect . stringContaining ( '"suggest":[{"answer":"not an array"}]' ) ,
193+ false ,
194+ )
195+ } )
196+
197+ it ( "should coerce a JSON string array follow_up into a proper array" , async ( ) => {
198+ const block : ToolUse = {
199+ type : "tool_use" ,
200+ name : "ask_followup_question" ,
201+ params : {
202+ question : "What would you like to do?" ,
203+ } ,
204+ nativeArgs : {
205+ question : "What would you like to do?" ,
206+ follow_up : '[{"text":"Option A"},{"text":"Option B","mode":"code"}]' as any ,
207+ } as any ,
208+ partial : false ,
209+ }
210+
211+ await askFollowupQuestionTool . handle ( mockCline , block as ToolUse < "ask_followup_question" > , {
212+ askApproval : vi . fn ( ) ,
213+ handleError : vi . fn ( ) ,
214+ pushToolResult : mockPushToolResult ,
215+ } )
216+
217+ // JSON string should be parsed into a proper array
218+ expect ( mockCline . ask ) . toHaveBeenCalledWith (
219+ "followup" ,
220+ expect . stringContaining ( '"suggest":[{"answer":"Option A"},{"answer":"Option B","mode":"code"}]' ) ,
221+ false ,
222+ )
223+ } )
224+
225+ it ( "should handle number follow_up parameter as missing" , async ( ) => {
226+ const block : ToolUse = {
227+ type : "tool_use" ,
228+ name : "ask_followup_question" ,
229+ params : {
230+ question : "What would you like to do?" ,
231+ } ,
232+ nativeArgs : {
233+ question : "What would you like to do?" ,
234+ follow_up : 42 as any ,
235+ } as any ,
236+ partial : false ,
237+ }
238+
239+ await askFollowupQuestionTool . handle ( mockCline , block as ToolUse < "ask_followup_question" > , {
240+ askApproval : vi . fn ( ) ,
241+ handleError : vi . fn ( ) ,
242+ pushToolResult : mockPushToolResult ,
243+ } )
244+
189245 expect ( mockCline . sayAndCreateMissingParamError ) . toHaveBeenCalledWith ( "ask_followup_question" , "follow_up" )
190- expect ( mockCline . recordToolError ) . toHaveBeenCalledWith ( "ask_followup_question" )
191- expect ( mockCline . didToolFailInCurrentTurn ) . toBe ( true )
192- expect ( mockCline . consecutiveMistakeCount ) . toBe ( 1 )
193246 expect ( mockCline . ask ) . not . toHaveBeenCalled ( )
194247 } )
195248 } )
196249
250+ describe ( "coerceFollowUp helper" , ( ) => {
251+ it ( "should return arrays as-is" , ( ) => {
252+ const input = [ { text : "Option 1" } , { text : "Option 2" } ]
253+ expect ( coerceFollowUp ( input ) ) . toEqual ( input )
254+ } )
255+
256+ it ( "should parse a JSON string containing an array" , ( ) => {
257+ const input = '[{"text":"A"},{"text":"B","mode":"code"}]'
258+ expect ( coerceFollowUp ( input ) ) . toEqual ( [ { text : "A" } , { text : "B" , mode : "code" } ] )
259+ } )
260+
261+ it ( "should wrap a plain string as a single suggestion" , ( ) => {
262+ expect ( coerceFollowUp ( "some option" ) ) . toEqual ( [ { text : "some option" } ] )
263+ } )
264+
265+ it ( "should return undefined for null" , ( ) => {
266+ expect ( coerceFollowUp ( null ) ) . toBeUndefined ( )
267+ } )
268+
269+ it ( "should return undefined for undefined" , ( ) => {
270+ expect ( coerceFollowUp ( undefined ) ) . toBeUndefined ( )
271+ } )
272+
273+ it ( "should return undefined for empty string" , ( ) => {
274+ expect ( coerceFollowUp ( "" ) ) . toBeUndefined ( )
275+ } )
276+
277+ it ( "should return undefined for whitespace-only string" , ( ) => {
278+ expect ( coerceFollowUp ( " " ) ) . toBeUndefined ( )
279+ } )
280+
281+ it ( "should wrap a JSON string that parses to a non-array as a suggestion" , ( ) => {
282+ // A JSON string like '{"text":"hello"}' is valid JSON but not an array
283+ expect ( coerceFollowUp ( '{"text":"hello"}' ) ) . toEqual ( [ { text : '{"text":"hello"}' } ] )
284+ } )
285+ } )
286+
197287 describe ( "handlePartial with native protocol" , ( ) => {
198288 it ( "should only send question during partial streaming to avoid raw JSON display" , async ( ) => {
199289 const block : ToolUse < "ask_followup_question" > = {
@@ -292,5 +382,48 @@ describe("askFollowupQuestionTool", () => {
292382 } )
293383 }
294384 } )
385+
386+ it ( "should coerce string follow_up to array during finalization" , ( ) => {
387+ NativeToolCallParser . startStreamingToolCall ( "call_789" , "ask_followup_question" )
388+
389+ // Simulate a model that outputs follow_up as a plain string
390+ const jsonWithStringFollowUp = '{"question":"Pick one","follow_up":"Option A"}'
391+ NativeToolCallParser . processStreamingChunk ( "call_789" , jsonWithStringFollowUp )
392+
393+ const result = NativeToolCallParser . finalizeStreamingToolCall ( "call_789" )
394+
395+ expect ( result ) . not . toBeNull ( )
396+ expect ( result ?. type ) . toBe ( "tool_use" )
397+ if ( result ?. type === "tool_use" ) {
398+ const nativeArgs = result . nativeArgs as {
399+ question : string
400+ follow_up : Array < { text : string ; mode ?: string } >
401+ }
402+ expect ( nativeArgs . question ) . toBe ( "Pick one" )
403+ expect ( nativeArgs . follow_up ) . toEqual ( [ { text : "Option A" } ] )
404+ }
405+ } )
406+
407+ it ( "should coerce JSON-string follow_up to array during finalization" , ( ) => {
408+ NativeToolCallParser . startStreamingToolCall ( "call_101" , "ask_followup_question" )
409+
410+ // Simulate a model that outputs follow_up as a JSON string of an array
411+ const jsonWithJsonStringFollowUp =
412+ '{"question":"Pick one","follow_up":"[{\\"text\\":\\"A\\"},{\\"text\\":\\"B\\"}]"}'
413+ NativeToolCallParser . processStreamingChunk ( "call_101" , jsonWithJsonStringFollowUp )
414+
415+ const result = NativeToolCallParser . finalizeStreamingToolCall ( "call_101" )
416+
417+ expect ( result ) . not . toBeNull ( )
418+ expect ( result ?. type ) . toBe ( "tool_use" )
419+ if ( result ?. type === "tool_use" ) {
420+ const nativeArgs = result . nativeArgs as {
421+ question : string
422+ follow_up : Array < { text : string ; mode ?: string } >
423+ }
424+ expect ( nativeArgs . question ) . toBe ( "Pick one" )
425+ expect ( nativeArgs . follow_up ) . toEqual ( [ { text : "A" } , { text : "B" } ] )
426+ }
427+ } )
295428 } )
296429} )
0 commit comments