Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions packages/types/src/__tests__/followup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
firstUsableSuggestion,
followUpDataSchema,
hasUsableAnswer,
suggestionItemSchema,
type SuggestionItem,
} from "../followup.js"

describe("hasUsableAnswer", () => {
it("accepts a non-blank string answer", () => {
expect(hasUsableAnswer({ answer: "Yes, proceed" })).toBe(true)
})

it("accepts a non-blank answer with surrounding whitespace, including one that also carries a mode", () => {
const withMode: SuggestionItem = { answer: " spaced ", mode: "code" }
expect(hasUsableAnswer(withMode)).toBe(true)
})

it("rejects an empty or whitespace-only answer", () => {
expect(hasUsableAnswer({ answer: "" })).toBe(false)
expect(hasUsableAnswer({ answer: " \n\t " })).toBe(false)
})

it("rejects a missing answer (issue #1226)", () => {
expect(hasUsableAnswer({})).toBe(false)
expect(hasUsableAnswer({ answer: undefined })).toBe(false)
})

it("rejects non-string answers from malformed transport data (issue #1226)", () => {
expect(hasUsableAnswer({ answer: 42 })).toBe(false)
expect(hasUsableAnswer({ answer: { mode_slug: "code" } })).toBe(false)
expect(hasUsableAnswer({ answer: null })).toBe(false)
})

it("rejects a null or undefined suggestion item", () => {
expect(hasUsableAnswer(null)).toBe(false)
expect(hasUsableAnswer(undefined)).toBe(false)
})
})

describe("firstUsableSuggestion", () => {
it("returns undefined for missing or empty suggestions", () => {
expect(firstUsableSuggestion(undefined)).toBeUndefined()
expect(firstUsableSuggestion(null)).toBeUndefined()
expect(firstUsableSuggestion([])).toBeUndefined()
})

it("skips blank or missing answers and returns the first usable item", () => {
const suggestions: SuggestionItem[] = [{}, { answer: " " }, { answer: " ok " }]
expect(firstUsableSuggestion(suggestions)).toEqual({ answer: " ok " })
})
})

describe("suggestionItemSchema", () => {
it("accepts a suggestion without an answer (issue #1226)", () => {
expect(suggestionItemSchema.parse({ mode: "code" })).toEqual({ mode: "code" })
})

it("still rejects a non-string answer", () => {
expect(() => suggestionItemSchema.parse({ answer: 42 })).toThrow()
})
})

describe("followUpDataSchema", () => {
it("accepts suggestions with a mix of usable and missing answers (issue #1226)", () => {
const parsed = followUpDataSchema.parse({
question: "Pick one?",
suggest: [{ answer: "Yes" }, { mode: "code" }, { answer: undefined }],
})

expect(parsed).toEqual({
question: "Pick one?",
suggest: [{ answer: "Yes" }, { mode: "code" }, {}],
})
})
})
37 changes: 34 additions & 3 deletions packages/types/src/followup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ export interface FollowUpData {
* Interface for a suggestion item with optional mode switching
*/
export interface SuggestionItem {
/** The text of the suggestion */
answer: string
/**
* The text of the suggestion.
*
* Optional because the model can emit malformed follow-up payloads with a
* missing or blank answer (issue #1226) and the extension-host transport
* does not validate `FollowUpData`. Guard with `hasUsableAnswer()` before
* treating the value as usable text.
*/
answer?: string
/** Optional mode to switch to when selecting this suggestion */
mode?: string
}
Expand All @@ -35,11 +42,35 @@ export const getSuggestionMode = (mode: unknown): string | undefined => {
return undefined
}

/**
* Whether a follow-up suggestion carries a usable answer: a non-blank string.
*
* `SuggestionItem.answer` is optional because a malformed model payload may
* omit it (issue #1226), and the unvalidated transport may deliver a
* non-string value at runtime. The extension-host auto-approval
* (`checkAutoApproval`), the `FollowUpSuggest` visible-suggestions filter, and
* `ChatView`'s suggestion click handler all guard through this helper so the
* definition of "usable answer" stays in one place.
*/
export const hasUsableAnswer = (
suggestion: { answer?: unknown } | null | undefined,
): suggestion is SuggestionItem & { answer: string } =>
typeof suggestion?.answer === "string" && suggestion.answer.trim().length > 0

/**
* The first suggestion with a usable answer, if any.
*
* Shared by the extension-host auto-approval (`checkAutoApproval`) and the
* webview so the "first usable suggestion" rule stays in one place.
*/
export const firstUsableSuggestion = (suggestions?: SuggestionItem[] | null): SuggestionItem | undefined =>
(suggestions ?? []).find((s) => hasUsableAnswer(s))

/**
* Zod schema for SuggestionItem
*/
export const suggestionItemSchema = z.object({
answer: z.string(),
answer: z.string().optional(),
mode: z.string().optional(),
})

Expand Down
112 changes: 112 additions & 0 deletions src/core/auto-approval/__tests__/followup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ExtensionState } from "@roo-code/types"
import { checkAutoApproval, type AutoApprovalState, type AutoApprovalStateOptions } from ".."

type AutoApprovalFields = Pick<ExtensionState, AutoApprovalState | AutoApprovalStateOptions>

describe("Follow-up question auto-approval", () => {
const baseState: AutoApprovalFields = {
autoApprovalEnabled: true,
alwaysAllowFollowupQuestions: true,
followupAutoApproveTimeoutMs: 10_000,
}

const followupText = (suggest: unknown) => JSON.stringify({ question: "Pick one?", suggest }) as string

const run = (state: AutoApprovalFields, text: string) => checkAutoApproval({ state, ask: "followup", text })

it("schedules a timeout that auto-answers with the first valid suggestion", async () => {
const result = await run(baseState, followupText([{ answer: "Yes, proceed" }]))

expect(result.decision).toBe("timeout")
if (result.decision === "timeout") {
expect(result.timeout).toBe(10_000)
expect(result.fn()).toEqual({
askResponse: "messageResponse",
text: "Yes, proceed",
})
}
})

it("falls back to asking when the follow-up has no text payload", async () => {
// Exercises the `text || "{}"` fallback: a follow-up without any payload must
// not schedule an auto-answer timeout.
const result = await checkAutoApproval({ state: baseState, ask: "followup" })

expect(result).toEqual({ decision: "ask" })
})

it("skips a blank or missing first answer and uses the next valid suggestion (issue #1226)", async () => {
// Mirrors a malformed model response where JSON round-tripping drops
// `answer: undefined` and the first item is unusable.
const result = await run(baseState, followupText([{}, { answer: " " }, { answer: "Valid answer" }]))

expect(result.decision).toBe("timeout")
if (result.decision === "timeout") {
expect(result.fn()).toEqual({
askResponse: "messageResponse",
text: "Valid answer",
})
}
})

it("skips a non-string first answer and uses the next valid suggestion (issue #1226)", async () => {
// A malformed payload may carry a non-string `answer`; the first usable
// suggestion still wins, so the auto-answer must not be dropped.
const result = await run(baseState, followupText([{ answer: 42 }, { answer: "Valid answer" }]))

expect(result.decision).toBe("timeout")
if (result.decision === "timeout") {
expect(result.fn()).toEqual({
askResponse: "messageResponse",
text: "Valid answer",
})
}
})

it("falls back to asking when every suggestion answer is blank or missing (issue #1226)", async () => {
// Before the #1226 fix this scheduled a timeout that auto-answered the
// follow-up with `undefined` text, silently accepting an empty answer.
const result = await run(baseState, followupText([{ answer: "" }, { answer: " \n\t " }, {}]))

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the suggestion answer is not a string", async () => {
const result = await run(baseState, followupText([{ answer: 42 }]))

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the follow-up has no suggestions", async () => {
const result = await run(baseState, JSON.stringify({ question: "Pick one?" }))

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the follow-up text is not valid JSON", async () => {
const result = await run(baseState, "not-json")

expect(result).toEqual({ decision: "ask" })
})

it("falls back to asking when the auto-approve timeout is not positive", async () => {
const result = await run({ ...baseState, followupAutoApproveTimeoutMs: 0 }, followupText([{ answer: "Yes" }]))

expect(result).toEqual({ decision: "ask" })
})

it("does not auto-approve when follow-up auto-approval is disabled", async () => {
const result = await run(
{ ...baseState, alwaysAllowFollowupQuestions: false },
followupText([{ answer: "Yes" }]),
)

expect(result).toEqual({ decision: "ask" })
})

it("does not auto-approve when global auto-approval is disabled", async () => {
const result = await run({ ...baseState, autoApprovalEnabled: false }, followupText([{ answer: "Yes" }]))

expect(result).toEqual({ decision: "ask" })
})
})
7 changes: 6 additions & 1 deletion src/core/auto-approval/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
firstUsableSuggestion,
type ClineAsk,
type ClineSayTool,
type McpServerUse,
Expand Down Expand Up @@ -179,7 +180,11 @@ export async function checkAutoApproval({
if (ask === "followup") {
if (state.alwaysAllowFollowupQuestions === true) {
try {
const suggestion = (JSON.parse(text || "{}") as FollowUpData).suggest?.[0]
// A missing or blank answer would auto-approve the follow-up with no
// content after the timeout (issue #1226), so pick the first suggestion
// with a usable answer. This mirrors the webview's visible-suggestions
// filter in FollowUpSuggest.
const suggestion = firstUsableSuggestion((JSON.parse(text || "{}") as FollowUpData).suggest)

if (
suggestion &&
Expand Down
Loading
Loading