From de8ac404be24f7e1af4726eaa5bc3e0c49b244a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Tue, 10 Nov 2026 23:51:17 -0800 Subject: [PATCH 1/4] fix(acp): hide other choice in question card when allow_other is false --- src-tauri/src/acp/connection.rs | 1 + src-tauri/src/acp/cursor_ext.rs | 1 + src-tauri/src/acp/delegation/listener.rs | 1 + src-tauri/src/acp/manager.rs | 1 + src-tauri/src/acp/question.rs | 22 +++++++++ .../src/chat_channel/event_subscriber.rs | 1 + .../src/chat_channel/message_formatter.rs | 1 + src-tauri/tests/delegation_e2e_uds.rs | 2 + .../chat/ask-question-card.test.tsx | 47 ++++++++++++++++++ src/components/chat/ask-question-card.tsx | 48 ++++++++++--------- .../message/ask-question-result-card.tsx | 1 + src/lib/ask-question.ts | 3 ++ src/lib/types.ts | 1 + 13 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 70fb0bb6d32..9acda44da74 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -50668,6 +50668,7 @@ mod tests { }) .collect(), is_secret: false, + allow_other: false, }; let answered = crate::acp::question::QuestionOutcome { answers: vec![crate::acp::question::QuestionAnsweredItem { diff --git a/src-tauri/src/acp/cursor_ext.rs b/src-tauri/src/acp/cursor_ext.rs index b54f22f7c18..80c3f0ab87f 100644 --- a/src-tauri/src/acp/cursor_ext.rs +++ b/src-tauri/src/acp/cursor_ext.rs @@ -187,6 +187,7 @@ pub fn parse_cursor_ask_questions(params: &Value) -> Result bool { + true +} + /// A single multiple-choice question. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct QuestionSpec { @@ -91,6 +95,11 @@ pub struct QuestionSpec { /// input. Default false — absent on the wire for every non-secret source. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub is_secret: bool, + /// When false, the question card hides the "Other" option and free-text input. + /// Defaults to true; false for native permission bridges (Antigravity, Pi select) + /// where only predetermined option IDs can be submitted. + #[serde(default = "default_true")] + pub allow_other: bool, } /// The pending (awaiting-answer) question set stored on @@ -310,6 +319,7 @@ pub fn parse_questions(arguments: &Value) -> Result, String> { multi_select, options, is_secret: false, + allow_other: true, }); } Ok(out) @@ -567,6 +577,7 @@ pub fn parse_grok_ext_questions(params: &Value) -> Result, Str multi_select, options, is_secret: false, + allow_other: true, }); } Ok(out) @@ -675,6 +686,7 @@ pub fn parse_pi_select_ask(tool_call: &Value, options: &[(String, String)]) -> O multi_select: false, options: choices, is_secret: false, + allow_other: false, }, option_ids, }) @@ -803,6 +815,7 @@ pub fn parse_antigravity_ask( multi_select: false, options: choices, is_secret: false, + allow_other: false, }, option_ids, }) @@ -1827,6 +1840,7 @@ fn parse_form_questions( multi_select, options, is_secret: is_secret_property(raw, id), + allow_other: true, }); fields.push(ElicitationField { other_field_id: minimax_companions.get(id).cloned(), @@ -2038,6 +2052,7 @@ pub fn grok_result_card_input(specs: &[QuestionSpec]) -> Value { "question": s.question, "multiSelect": s.multi_select, "options": s.options, + "allowOther": s.allow_other, }) }) .collect(); @@ -2128,6 +2143,7 @@ mod tests { assert!(!qs[0].multi_select); assert_eq!(qs[0].options.len(), 2); assert!(!qs[0].id.is_empty()); + assert!(qs[0].allow_other); } fn elicitation_raw(props: Value, required: Value) -> Value { @@ -3554,6 +3570,7 @@ mod tests { }) .collect(), is_secret: false, + allow_other: true, }; assert!(validate_specs(&[]).is_err(), "empty set"); @@ -3597,6 +3614,7 @@ mod tests { }, ], is_secret: false, + allow_other: true, }; assert!(validate_specs(&[blank_id]).is_err(), "blank id"); // Duplicate option label within one question (parse_questions rejects it). @@ -3616,6 +3634,7 @@ mod tests { }, ], is_secret: false, + allow_other: true, }; assert!( validate_specs(&[dup_label]).is_err(), @@ -3935,6 +3954,7 @@ mod tests { ); // `ctx.ui.select` yields one value. assert!(!ask.spec.multi_select); + assert!(!ask.spec.allow_other); assert_eq!(ask.spec.options.len(), 4); assert_eq!( ask.spec.options[0].label, @@ -4254,6 +4274,7 @@ mod tests { .expect("实测交互格式应转换为问题卡片"); assert_eq!(ask.spec.options[1].label, "前端开发"); assert!(!ask.spec.multi_select); + assert!(!ask.spec.allow_other); assert_eq!(ask.option_ids[1].1, "opaque-second"); let mut no_kind = antigravity_interaction(); @@ -4261,6 +4282,7 @@ mod tests { let ask_no_kind = parse_antigravity_ask(&no_kind, &antigravity_choices()) .expect("实测 antigravity-acp 1.4.1 省略 kind 字段亦应正确识别为问题卡片"); assert_eq!(ask_no_kind.spec.options[1].label, "前端开发"); + assert!(!ask_no_kind.spec.allow_other); } #[test] diff --git a/src-tauri/src/chat_channel/event_subscriber.rs b/src-tauri/src/chat_channel/event_subscriber.rs index 699501015f5..e051ecb716d 100644 --- a/src-tauri/src/chat_channel/event_subscriber.rs +++ b/src-tauri/src/chat_channel/event_subscriber.rs @@ -851,6 +851,7 @@ mod permission_push_tests { }, ], is_secret: false, + allow_other: true, }], }, } diff --git a/src-tauri/src/chat_channel/message_formatter.rs b/src-tauri/src/chat_channel/message_formatter.rs index 3334606cde9..9e2c7732882 100644 --- a/src-tauri/src/chat_channel/message_formatter.rs +++ b/src-tauri/src/chat_channel/message_formatter.rs @@ -227,6 +227,7 @@ mod question_request_tests { }) .collect(), is_secret: false, + allow_other: true, } } diff --git a/src-tauri/tests/delegation_e2e_uds.rs b/src-tauri/tests/delegation_e2e_uds.rs index 8763822031e..39ce6e5406b 100644 --- a/src-tauri/tests/delegation_e2e_uds.rs +++ b/src-tauri/tests/delegation_e2e_uds.rs @@ -554,6 +554,7 @@ async fn end_to_end_uds_ask_question_round_trip() { }, ], is_secret: false, + allow_other: true, }], }; client_ask_round_trip(&socket_str, &req).await @@ -697,6 +698,7 @@ async fn end_to_end_uds_ask_revoked_after_register_declines() { }, ], is_secret: false, + allow_other: true, }], }; let resp = client_ask_round_trip(&socket.to_string_lossy(), &req) diff --git a/src/components/chat/ask-question-card.test.tsx b/src/components/chat/ask-question-card.test.tsx index 4976f734b5f..1df9890bb0a 100644 --- a/src/components/chat/ask-question-card.test.tsx +++ b/src/components/chat/ask-question-card.test.tsx @@ -189,6 +189,53 @@ describe("AskQuestionCard", () => { expect(screen.queryByRole("radio")).not.toBeInTheDocument() }) + it("hides the Other option when allow_other is false in single-select", () => { + renderCard({ + question_id: "q-no-other-single", + created_at: "2026-01-01T00:00:00Z", + questions: [ + { + id: "qa", + question: "Which approach?", + header: "Approach", + multi_select: false, + allow_other: false, + options: [ + { label: "Incremental", description: "smaller diffs" }, + { label: "Rewrite", description: "clean slate" }, + ], + }, + ], + }) + expect(screen.getAllByRole("radio")).toHaveLength(2) + expect(screen.queryByRole("radio", { name: "Other" })).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText("Type your answer?")).not.toBeInTheDocument() + }) + + it("hides the Other option when allow_other is false in multi-select", () => { + renderCard({ + question_id: "q-no-other-multi", + created_at: "2026-01-01T00:00:00Z", + questions: [ + { + id: "qb", + question: "Which modules?", + header: "Scope", + multi_select: true, + allow_other: false, + options: [ + { label: "auth", description: "" }, + { label: "billing", description: "" }, + { label: "ui", description: "" }, + ], + }, + ], + }) + expect(screen.getAllByRole("checkbox")).toHaveLength(3) + expect(screen.queryByRole("checkbox", { name: "Other" })).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText("Type your answer?")).not.toBeInTheDocument() + }) + it("submits the typed Other text as the answer label", () => { const onAnswer = renderCard(single) fireEvent.click(screen.getByRole("radio", { name: "Other" })) diff --git a/src/components/chat/ask-question-card.tsx b/src/components/chat/ask-question-card.tsx index c78286c17eb..09c5043c579 100644 --- a/src/components/chat/ask-question-card.tsx +++ b/src/components/chat/ask-question-card.tsx @@ -127,7 +127,7 @@ export function AskQuestionCard({ // tallest question (its option count plus the always-present "Other" row), // capped to the viewport; a taller-than-estimated tab scrolls internally. const maxRows = useMemo( - () => questions.reduce((m, q) => Math.max(m, q.options.length + 1), 1), + () => questions.reduce((m, q) => Math.max(m, q.options.length + (q.allow_other === false ? 0 : 1)), 1), [questions] ) const bodyHeight = `min(${maxRows * 4 + 1}rem, 50svh)` @@ -377,16 +377,18 @@ export function AskQuestionCard({ ) })} - - {otherInput} + {q.allow_other !== false && ( + + )} + {q.allow_other !== false && otherInput} ) } @@ -423,18 +425,20 @@ export function AskQuestionCard({ ) })} - + {q.allow_other !== false && ( + + )} - {otherInput} + {q.allow_other !== false && otherInput} ) } diff --git a/src/components/message/ask-question-result-card.tsx b/src/components/message/ask-question-result-card.tsx index 5fa34da1d66..5600bcdf890 100644 --- a/src/components/message/ask-question-result-card.tsx +++ b/src/components/message/ask-question-result-card.tsx @@ -78,6 +78,7 @@ export function AskQuestionResultCard({ multi_select: q.multiSelect, options: q.options, is_secret: q.isSecret, + allow_other: q.allowOther, })), } }, [questions]) diff --git a/src/lib/ask-question.ts b/src/lib/ask-question.ts index cedac88c7c0..d2f8d1e3349 100644 --- a/src/lib/ask-question.ts +++ b/src/lib/ask-question.ts @@ -30,6 +30,7 @@ export interface AskQuestion { * answer envelope is keyed by that id rather than the question text (unlike * the codeg-mcp ask, which the card matches by header+question). */ id?: string + allowOther?: boolean } export interface AskQuestionAnswer { @@ -146,6 +147,8 @@ export function parseAskQuestionInput( options, isSecret: obj.isSecret === true || obj.is_secret === true, id: typeof obj.id === "string" && obj.id ? obj.id : undefined, + allowOther: + obj.allowOther === false || obj.allow_other === false ? false : true, }) } return out diff --git a/src/lib/types.ts b/src/lib/types.ts index 6748fd130dc..e03e4fbefc4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1631,6 +1631,7 @@ export interface QuestionSpec { multi_select: boolean options: QuestionOption[] is_secret?: boolean + allow_other?: boolean } /** Awaiting-answer question set on the session (mirror of `PendingQuestionState`). */ From c713c5a11d295cc68533577be1a5c9b0ba7c0e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Tue, 10 Nov 2026 23:58:44 -0800 Subject: [PATCH 2/4] ci: trigger CI for ask question allow other on fork From 23bead828396629832144850c1127d9e37efbd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Wed, 11 Nov 2026 16:10:48 +0800 Subject: [PATCH 3/4] style: format ask-question-card and tests with prettier --- src/components/chat/ask-question-card.test.tsx | 16 ++++++++++++---- src/components/chat/ask-question-card.tsx | 7 ++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/chat/ask-question-card.test.tsx b/src/components/chat/ask-question-card.test.tsx index 1df9890bb0a..f7b55f82d57 100644 --- a/src/components/chat/ask-question-card.test.tsx +++ b/src/components/chat/ask-question-card.test.tsx @@ -208,8 +208,12 @@ describe("AskQuestionCard", () => { ], }) expect(screen.getAllByRole("radio")).toHaveLength(2) - expect(screen.queryByRole("radio", { name: "Other" })).not.toBeInTheDocument() - expect(screen.queryByPlaceholderText("Type your answer?")).not.toBeInTheDocument() + expect( + screen.queryByRole("radio", { name: "Other" }) + ).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText("Type your answer?") + ).not.toBeInTheDocument() }) it("hides the Other option when allow_other is false in multi-select", () => { @@ -232,8 +236,12 @@ describe("AskQuestionCard", () => { ], }) expect(screen.getAllByRole("checkbox")).toHaveLength(3) - expect(screen.queryByRole("checkbox", { name: "Other" })).not.toBeInTheDocument() - expect(screen.queryByPlaceholderText("Type your answer?")).not.toBeInTheDocument() + expect( + screen.queryByRole("checkbox", { name: "Other" }) + ).not.toBeInTheDocument() + expect( + screen.queryByPlaceholderText("Type your answer?") + ).not.toBeInTheDocument() }) it("submits the typed Other text as the answer label", () => { diff --git a/src/components/chat/ask-question-card.tsx b/src/components/chat/ask-question-card.tsx index 09c5043c579..4db7caf2355 100644 --- a/src/components/chat/ask-question-card.tsx +++ b/src/components/chat/ask-question-card.tsx @@ -127,7 +127,12 @@ export function AskQuestionCard({ // tallest question (its option count plus the always-present "Other" row), // capped to the viewport; a taller-than-estimated tab scrolls internally. const maxRows = useMemo( - () => questions.reduce((m, q) => Math.max(m, q.options.length + (q.allow_other === false ? 0 : 1)), 1), + () => + questions.reduce( + (m, q) => + Math.max(m, q.options.length + (q.allow_other === false ? 0 : 1)), + 1 + ), [questions] ) const bodyHeight = `min(${maxRows * 4 + 1}rem, 50svh)` From 76e7eb163eff6f4b15f7456111cf1c0337d60bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Wed, 11 Nov 2026 16:27:20 +0800 Subject: [PATCH 4/4] fix(lib): keep allowOther undefined when absent in ask input --- src/lib/ask-question.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/ask-question.ts b/src/lib/ask-question.ts index d2f8d1e3349..ae7d00c43ce 100644 --- a/src/lib/ask-question.ts +++ b/src/lib/ask-question.ts @@ -148,7 +148,11 @@ export function parseAskQuestionInput( isSecret: obj.isSecret === true || obj.is_secret === true, id: typeof obj.id === "string" && obj.id ? obj.id : undefined, allowOther: - obj.allowOther === false || obj.allow_other === false ? false : true, + obj.allowOther === false || obj.allow_other === false + ? false + : obj.allowOther === true || obj.allow_other === true + ? true + : undefined, }) } return out