diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3586ce0b6..a5a03faaa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -43,6 +43,7 @@ ### Fixed +- Fixed an uncaught `questions.map is not a function` TUI crash in the ask tool's call renderer when a model double-encoded the `questions` array as a JSON string (a bare string passes a truthy `.length` check but has no `.map`): the renderer now normalizes untrusted call args — parsing double-encoded `questions`, dropping malformed entries/options, and falling back to the "No question provided" frame instead of throwing - Fixed model-provider detection for append-only mode, authoritative Vertex endpoint checks, and upstream-routing selection by switching from URL substring checks to catalog host-matching helpers - Fixed pasting into the ask tool's "Other (type your own)" text box (and hook input/editor dialogs) on terminals with OSC 5522 enhanced paste (kitty protocol): the enhanced-paste focus routing only targets components exposing a `pasteText` hook, and the dialog wrappers had none, so the payload was stuffed into the main prompt editor hidden behind the dialog. `HookEditorComponent` and `HookInputComponent` now forward `pasteText` to their inner editor/input (pasting also resets the input dialog's timeout countdown like any keystroke). - Fixed auto-retry giving up after one attempt ("Provider requested Xms wait, exceeds retry.maxDelayMs") on a usage-limit 429 when every sibling account was only momentarily blocked: the retry delay now waits for the earliest sibling unblock when that comes sooner than the provider's multi-hour retry-after, so the next attempt picks up the recovered account instead of failing fast. diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 9230c0c8a..96a3853b6 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -641,6 +641,56 @@ interface AskRenderArgs { }>; } +/** + * Coerce an untrusted option list (streamed or model-mangled call args) into + * well-formed render options. Bare strings become labels; entries without a + * string label are dropped. + */ +function normalizeRenderOptions(raw: unknown): AskRenderOption[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: AskRenderOption[] = []; + for (const entry of raw) { + if (typeof entry === "string") { + out.push({ label: entry }); + continue; + } + if (!entry || typeof entry !== "object") continue; + const { label, description } = entry as Partial; + if (typeof label !== "string") continue; + out.push(typeof description === "string" ? { label, description } : { label }); + } + return out; +} + +/** + * Coerce untrusted `questions` call args into a renderable array. Models + * occasionally double-encode the array as a JSON string — a bare string passes + * a truthy `.length` check but has no `.map`, which used to crash the TUI + * render loop. Partially streamed args can also be missing fields. + */ +function normalizeRenderQuestions(raw: unknown): NonNullable | undefined { + if (typeof raw === "string") { + try { + raw = JSON.parse(raw); + } catch { + return undefined; + } + } + if (!Array.isArray(raw)) return undefined; + const out: NonNullable = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") continue; + const q = entry as Partial[number]>; + out.push({ + id: typeof q.id === "string" ? q.id : "?", + question: typeof q.question === "string" ? q.question : "", + options: normalizeRenderOptions(q.options) ?? [], + multi: q.multi === true, + }); + } + return out; +} + /** Render a custom free-text answer as a status line plus indented continuation rows. */ function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] { const lines = customInput.split("\n"); @@ -724,8 +774,10 @@ export const askToolRenderer = { new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, width - 3 + 1)); // Multi-part questions: one divider-labelled section per question. - if (args.questions && args.questions.length > 0) { - const questions = args.questions; + // Call args are untrusted (partially streamed or model-mangled) and a + // throw here takes down the whole TUI render loop — normalize first. + const questions = normalizeRenderQuestions(args.questions); + if (questions && questions.length > 0) { const header = `${label} ${uiTheme.fg("muted", `${questions.length} questions`)}`; return framedBlock(uiTheme, width => { const sections = questions.map(q => { @@ -742,7 +794,7 @@ export const askToolRenderer = { } // Single question - if (!args.question) { + if (typeof args.question !== "string" || !args.question) { const errorLine = formatErrorMessage("No question provided", uiTheme); return framedBlock(uiTheme, width => ({ header: errorLine, @@ -756,9 +808,9 @@ export const askToolRenderer = { const question = args.question; const meta: string[] = []; if (args.multi) meta.push("multi"); - if (args.options?.length) meta.push(`options:${args.options.length}`); + const questionOptions = normalizeRenderOptions(args.options); + if (questionOptions?.length) meta.push(`options:${questionOptions.length}`); const header = `${label}${formatMeta(meta, uiTheme)}`; - const questionOptions = args.options; const multi = args.multi; return framedBlock(uiTheme, width => { const bodyLines = md(question, width); diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 832b4b2ae..475c4fb61 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -1237,3 +1237,59 @@ describe("AskTool option markers", () => { expect(text).not.toContain(theme!.radio.selected); }); }); + +describe("askToolRenderer malformed call args", () => { + it("renders double-encoded questions string instead of crashing the TUI", async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + // Models occasionally JSON-encode the questions array as a string; a bare + // string passes a truthy `.length` check but has no `.map` (TUI crash). + const doubleEncoded = JSON.stringify([ + { id: "q1", question: "Pick one", options: [{ label: "Alpha" }, { label: "Beta" }] }, + ]); + const rendered = askToolRenderer.renderCall( + { questions: doubleEncoded } as never, + { expanded: true, isPartial: false }, + theme!, + ); + const text = stripAnsi(rendered.render(120).join("\n")); + expect(text).toContain("[q1]"); + expect(text).toContain("Pick one"); + expect(text).toContain("Alpha"); + }); + + it("falls back to the error frame for unparseable questions without throwing", async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + for (const questions of ["[{trunc", 42, { 0: { id: "x" } }]) { + const rendered = askToolRenderer.renderCall( + { questions } as never, + { expanded: true, isPartial: true }, + theme!, + ); + const text = stripAnsi(rendered.render(120).join("\n")); + expect(text).toContain("No question provided"); + } + }); + + it("drops malformed question entries and option items while keeping valid ones", async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const rendered = askToolRenderer.renderCall( + { + questions: [ + null, + "garbage", + { id: "ok", question: "Real question", options: ["BareString", { label: "Proper" }, { nope: 1 }, 7] }, + ], + } as never, + { expanded: true, isPartial: true }, + theme!, + ); + const text = stripAnsi(rendered.render(120).join("\n")); + expect(text).toContain("[ok]"); + expect(text).toContain("Real question"); + expect(text).toContain("BareString"); + expect(text).toContain("Proper"); + }); +});