Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 967fa19

Browse files
committed
fix: coerce string follow_up to array in ask_followup_question tool
Some models (e.g. Qwen3.6 35B-A3B) output the follow_up parameter as a string instead of the required array format. This adds a coerceFollowUp helper that normalizes: - JSON strings that parse to arrays - Plain strings (wrapped as single suggestion) - Existing arrays (passed through unchanged) Applied in AskFollowupQuestionTool.execute() and both partial/finalize handlers in NativeToolCallParser. Closes #12233
1 parent ad25634 commit 967fa19

3 files changed

Lines changed: 199 additions & 8 deletions

File tree

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,9 +475,20 @@ export class NativeToolCallParser {
475475

476476
case "ask_followup_question":
477477
if (partialArgs.question !== undefined || partialArgs.follow_up !== undefined) {
478+
let coercedPartialFollowUp = partialArgs.follow_up
479+
if (!Array.isArray(coercedPartialFollowUp) && typeof coercedPartialFollowUp === "string") {
480+
try {
481+
const parsed = JSON.parse(coercedPartialFollowUp)
482+
coercedPartialFollowUp = Array.isArray(parsed) ? parsed : undefined
483+
} catch {
484+
coercedPartialFollowUp = undefined
485+
}
486+
} else if (!Array.isArray(coercedPartialFollowUp)) {
487+
coercedPartialFollowUp = undefined
488+
}
478489
nativeArgs = {
479490
question: partialArgs.question,
480-
follow_up: Array.isArray(partialArgs.follow_up) ? partialArgs.follow_up : undefined,
491+
follow_up: coercedPartialFollowUp,
481492
}
482493
}
483494
break
@@ -820,9 +831,21 @@ export class NativeToolCallParser {
820831

821832
case "ask_followup_question":
822833
if (args.question !== undefined && args.follow_up !== undefined) {
834+
let coercedFinalFollowUp = args.follow_up
835+
if (!Array.isArray(coercedFinalFollowUp) && typeof coercedFinalFollowUp === "string") {
836+
const trimmed = (coercedFinalFollowUp as string).trim()
837+
if (trimmed.length > 0) {
838+
try {
839+
const parsed = JSON.parse(trimmed)
840+
coercedFinalFollowUp = Array.isArray(parsed) ? parsed : [{ text: trimmed }]
841+
} catch {
842+
coercedFinalFollowUp = [{ text: trimmed }]
843+
}
844+
}
845+
}
823846
nativeArgs = {
824847
question: args.question,
825-
follow_up: args.follow_up,
848+
follow_up: coercedFinalFollowUp,
826849
} as NativeArgsFor<TName>
827850
}
828851
break

src/core/tools/AskFollowupQuestionTool.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,46 @@ interface AskFollowupQuestionParams {
1414
follow_up: Suggestion[]
1515
}
1616

17+
/**
18+
* Coerce a follow_up value from various formats into the expected Suggestion array.
19+
* Some models (e.g. smaller Qwen models) output follow_up as a string instead of an array.
20+
* This helper normalizes the value so the tool works regardless of the model's output format.
21+
*
22+
* Supported coercions:
23+
* - Already an array: returned as-is
24+
* - A JSON string that parses to an array: parsed and returned
25+
* - A plain string: wrapped as a single suggestion `[{ text: value }]`
26+
* - Anything else (null, undefined, number, etc.): returns undefined so callers can error
27+
*/
28+
export function coerceFollowUp(value: unknown): Suggestion[] | undefined {
29+
if (Array.isArray(value)) {
30+
return value
31+
}
32+
33+
if (typeof value === "string" && value.trim().length > 0) {
34+
// Try parsing as JSON first (model may have serialized the array as a string)
35+
try {
36+
const parsed = JSON.parse(value)
37+
if (Array.isArray(parsed)) {
38+
return parsed
39+
}
40+
} catch {
41+
// Not valid JSON -- fall through to plain-string wrapping
42+
}
43+
44+
// Wrap plain string as a single suggestion
45+
return [{ text: value }]
46+
}
47+
48+
return undefined
49+
}
50+
1751
export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
1852
readonly name = "ask_followup_question" as const
1953

2054
async execute(params: AskFollowupQuestionParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
21-
const { question, follow_up } = params
55+
const { question } = params
56+
const follow_up = coerceFollowUp(params.follow_up)
2257
const { handleError, pushToolResult } = callbacks
2358

2459
const recordMissingParamError = async (paramName: string): Promise<void> => {

src/core/tools/__tests__/askFollowupQuestionTool.spec.ts

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { askFollowupQuestionTool } from "../AskFollowupQuestionTool"
1+
import { askFollowupQuestionTool, coerceFollowUp } from "../AskFollowupQuestionTool"
22
import { ToolUse } from "../../../shared/tools"
33
import { 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

Comments
 (0)