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

Commit d012af9

Browse files
committed
fix: ensure coerced follow_up suggestions always include mode field
The tool schema requires mode on each suggestion item (required: ["text", "mode"]). When follow_up was coerced from a string, the mode field was missing entirely. Some model Jinja templates (e.g. Qwen via llama.cpp) fail with "Cannot convert value of type Optional<Any> to Jinja Value" when a required field is absent from tool call arguments in conversation history. Now all coercion paths (coerceFollowUp helper + NativeToolCallParser partial and finalization handlers) normalize every suggestion to include mode: null when not explicitly set.
1 parent 967fa19 commit d012af9

3 files changed

Lines changed: 66 additions & 21 deletions

File tree

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -479,11 +479,18 @@ export class NativeToolCallParser {
479479
if (!Array.isArray(coercedPartialFollowUp) && typeof coercedPartialFollowUp === "string") {
480480
try {
481481
const parsed = JSON.parse(coercedPartialFollowUp)
482-
coercedPartialFollowUp = Array.isArray(parsed) ? parsed : undefined
482+
coercedPartialFollowUp = Array.isArray(parsed)
483+
? parsed.map((s: any) => ({ text: s.text, mode: s.mode ?? null }))
484+
: undefined
483485
} catch {
484486
coercedPartialFollowUp = undefined
485487
}
486-
} else if (!Array.isArray(coercedPartialFollowUp)) {
488+
} else if (Array.isArray(coercedPartialFollowUp)) {
489+
coercedPartialFollowUp = coercedPartialFollowUp.map((s: any) => ({
490+
text: s.text,
491+
mode: s.mode ?? null,
492+
}))
493+
} else {
487494
coercedPartialFollowUp = undefined
488495
}
489496
nativeArgs = {
@@ -837,11 +844,18 @@ export class NativeToolCallParser {
837844
if (trimmed.length > 0) {
838845
try {
839846
const parsed = JSON.parse(trimmed)
840-
coercedFinalFollowUp = Array.isArray(parsed) ? parsed : [{ text: trimmed }]
847+
coercedFinalFollowUp = Array.isArray(parsed)
848+
? parsed.map((s: any) => ({ text: s.text, mode: s.mode ?? null }))
849+
: [{ text: trimmed, mode: null }]
841850
} catch {
842-
coercedFinalFollowUp = [{ text: trimmed }]
851+
coercedFinalFollowUp = [{ text: trimmed, mode: null }]
843852
}
844853
}
854+
} else if (Array.isArray(coercedFinalFollowUp)) {
855+
coercedFinalFollowUp = coercedFinalFollowUp.map((s: any) => ({
856+
text: s.text,
857+
mode: s.mode ?? null,
858+
}))
845859
}
846860
nativeArgs = {
847861
question: args.question,

src/core/tools/AskFollowupQuestionTool.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BaseTool, ToolCallbacks } from "./BaseTool"
66

77
interface Suggestion {
88
text: string
9-
mode?: string
9+
mode?: string | null
1010
}
1111

1212
interface AskFollowupQuestionParams {
@@ -27,27 +27,39 @@ interface AskFollowupQuestionParams {
2727
*/
2828
export function coerceFollowUp(value: unknown): Suggestion[] | undefined {
2929
if (Array.isArray(value)) {
30-
return value
30+
return normalizeSuggestions(value)
3131
}
3232

3333
if (typeof value === "string" && value.trim().length > 0) {
3434
// Try parsing as JSON first (model may have serialized the array as a string)
3535
try {
3636
const parsed = JSON.parse(value)
3737
if (Array.isArray(parsed)) {
38-
return parsed
38+
return normalizeSuggestions(parsed)
3939
}
4040
} catch {
4141
// Not valid JSON -- fall through to plain-string wrapping
4242
}
4343

4444
// Wrap plain string as a single suggestion
45-
return [{ text: value }]
45+
return [{ text: value, mode: null }]
4646
}
4747

4848
return undefined
4949
}
5050

51+
/**
52+
* Ensure every suggestion has an explicit `mode` field (defaulting to `null`).
53+
* The tool schema requires `mode` on each item (`required: ["text", "mode"]`),
54+
* and some model Jinja templates fail if a required field is absent.
55+
*/
56+
function normalizeSuggestions(items: Suggestion[]): Suggestion[] {
57+
return items.map((s) => ({
58+
text: s.text,
59+
mode: s.mode ?? null,
60+
}))
61+
}
62+
5163
export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
5264
readonly name = "ask_followup_question" as const
5365

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

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe("askFollowupQuestionTool", () => {
4545

4646
expect(mockCline.ask).toHaveBeenCalledWith(
4747
"followup",
48-
expect.stringContaining('"suggest":[{"answer":"Option 1"},{"answer":"Option 2"}]'),
48+
expect.stringContaining('"suggest":[{"answer":"Option 1","mode":null},{"answer":"Option 2","mode":null}]'),
4949
false,
5050
)
5151
})
@@ -105,7 +105,7 @@ describe("askFollowupQuestionTool", () => {
105105
expect(mockCline.ask).toHaveBeenCalledWith(
106106
"followup",
107107
expect.stringContaining(
108-
'"suggest":[{"answer":"Regular option"},{"answer":"Plan architecture","mode":"architect"}]',
108+
'"suggest":[{"answer":"Regular option","mode":null},{"answer":"Plan architecture","mode":"architect"}]',
109109
),
110110
false,
111111
)
@@ -189,7 +189,7 @@ describe("askFollowupQuestionTool", () => {
189189
// Plain string should be coerced to [{ text: "not an array" }]
190190
expect(mockCline.ask).toHaveBeenCalledWith(
191191
"followup",
192-
expect.stringContaining('"suggest":[{"answer":"not an array"}]'),
192+
expect.stringContaining('"suggest":[{"answer":"not an array","mode":null}]'),
193193
false,
194194
)
195195
})
@@ -217,7 +217,9 @@ describe("askFollowupQuestionTool", () => {
217217
// JSON string should be parsed into a proper array
218218
expect(mockCline.ask).toHaveBeenCalledWith(
219219
"followup",
220-
expect.stringContaining('"suggest":[{"answer":"Option A"},{"answer":"Option B","mode":"code"}]'),
220+
expect.stringContaining(
221+
'"suggest":[{"answer":"Option A","mode":null},{"answer":"Option B","mode":"code"}]',
222+
),
221223
false,
222224
)
223225
})
@@ -248,18 +250,32 @@ describe("askFollowupQuestionTool", () => {
248250
})
249251

250252
describe("coerceFollowUp helper", () => {
251-
it("should return arrays as-is", () => {
253+
it("should normalize arrays to include mode: null when missing", () => {
252254
const input = [{ text: "Option 1" }, { text: "Option 2" }]
253-
expect(coerceFollowUp(input)).toEqual(input)
255+
expect(coerceFollowUp(input)).toEqual([
256+
{ text: "Option 1", mode: null },
257+
{ text: "Option 2", mode: null },
258+
])
259+
})
260+
261+
it("should preserve existing mode values in arrays", () => {
262+
const input = [{ text: "Option 1", mode: "code" }, { text: "Option 2" }]
263+
expect(coerceFollowUp(input)).toEqual([
264+
{ text: "Option 1", mode: "code" },
265+
{ text: "Option 2", mode: null },
266+
])
254267
})
255268

256269
it("should parse a JSON string containing an array", () => {
257270
const input = '[{"text":"A"},{"text":"B","mode":"code"}]'
258-
expect(coerceFollowUp(input)).toEqual([{ text: "A" }, { text: "B", mode: "code" }])
271+
expect(coerceFollowUp(input)).toEqual([
272+
{ text: "A", mode: null },
273+
{ text: "B", mode: "code" },
274+
])
259275
})
260276

261-
it("should wrap a plain string as a single suggestion", () => {
262-
expect(coerceFollowUp("some option")).toEqual([{ text: "some option" }])
277+
it("should wrap a plain string as a single suggestion with mode: null", () => {
278+
expect(coerceFollowUp("some option")).toEqual([{ text: "some option", mode: null }])
263279
})
264280

265281
it("should return undefined for null", () => {
@@ -278,9 +294,9 @@ describe("askFollowupQuestionTool", () => {
278294
expect(coerceFollowUp(" ")).toBeUndefined()
279295
})
280296

281-
it("should wrap a JSON string that parses to a non-array as a suggestion", () => {
297+
it("should wrap a JSON string that parses to a non-array as a suggestion with mode: null", () => {
282298
// A JSON string like '{"text":"hello"}' is valid JSON but not an array
283-
expect(coerceFollowUp('{"text":"hello"}')).toEqual([{ text: '{"text":"hello"}' }])
299+
expect(coerceFollowUp('{"text":"hello"}')).toEqual([{ text: '{"text":"hello"}', mode: null }])
284300
})
285301
})
286302

@@ -400,7 +416,7 @@ describe("askFollowupQuestionTool", () => {
400416
follow_up: Array<{ text: string; mode?: string }>
401417
}
402418
expect(nativeArgs.question).toBe("Pick one")
403-
expect(nativeArgs.follow_up).toEqual([{ text: "Option A" }])
419+
expect(nativeArgs.follow_up).toEqual([{ text: "Option A", mode: null }])
404420
}
405421
})
406422

@@ -422,7 +438,10 @@ describe("askFollowupQuestionTool", () => {
422438
follow_up: Array<{ text: string; mode?: string }>
423439
}
424440
expect(nativeArgs.question).toBe("Pick one")
425-
expect(nativeArgs.follow_up).toEqual([{ text: "A" }, { text: "B" }])
441+
expect(nativeArgs.follow_up).toEqual([
442+
{ text: "A", mode: null },
443+
{ text: "B", mode: null },
444+
])
426445
}
427446
})
428447
})

0 commit comments

Comments
 (0)