diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 0b6d9ef19a..665f1ef078 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -4711,16 +4711,20 @@ fn claude_raw_sdk_session_meta( /// process, where the agent's own OS sandbox and permission rules already /// apply (#436). Withholding one without the other buys nothing: the agent /// just reaches the same file through the channel it kept. -/// - Codex only: form elicitation, so codex's native Plan-mode -/// `request_user_input` is delivered as `elicitation/create` (handled by -/// `handle_elicitation_request`) instead of being silently answered `{}`. +/// - Codex, DeepSeek and Qoder only: form elicitation, so their native +/// questions (codex's Plan-mode `request_user_input`, DeepSeek's +/// `ask_user_question` and plan review, Qoder's `AskUserQuestion`) are +/// delivered as `elicitation/create` (handled by +/// `handle_elicitation_request`). Without it codex silently answers `{}`, +/// DeepSeek falls back to permission buttons, and Qoder withholds the tool +/// from the model. /// NOTE this reroutes codex's WHOLE form-elicitation surface — MCP /// tool-call approvals and MCP-server forms included — so the handler must /// cover every shape (`classify_elicitation`). URL elicitation is /// deliberately NOT advertised: codex-acp then falls back to /// `session/request_permission`, which codeg already handles. Scoped to -/// Codex to keep the blast radius off other agents (e.g. Claude's native -/// AskUserQuestion, which would otherwise un-gate and duplicate the +/// these agents to keep the blast radius off the others (e.g. Claude's +/// native AskUserQuestion, which would otherwise un-gate and duplicate the /// codeg-mcp ask tool). /// - Claude Code only: `_meta["subagent-transcript"] = true` — opt into /// claude-agent-acp ≥0.63's subagent transcript forwarding (#881, SDK @@ -4757,13 +4761,26 @@ fn build_client_capabilities( } // Form elicitation is advertised only to agents that are KNOWN to send // spec-conformant `elicitation/create` forms `classify_elicitation` can - // bridge: codex-acp (Plan-mode `request_user_input`, MCP forms/approvals) - // and deepseek-acp (its `ask_user_question` + plan-review both build - // standard oneOf/anyOf forms, and decode a free-text answer that is not in - // the option set as a custom answer — the card's "Other" input round-trips - // cleanly). Agents without the bit fall back to their own - // `request_permission` button path. - if matches!(agent_type, AgentType::Codex | AgentType::DeepSeek) { + // bridge: codex-acp (Plan-mode `request_user_input`, MCP forms/approvals), + // deepseek-acp (its `ask_user_question` + plan-review both build standard + // oneOf/anyOf forms, and decode a free-text answer that is not in the + // option set as a custom answer — the card's "Other" input round-trips + // cleanly), and qodercli (its native `AskUserQuestion` is the one thing it + // sends as a form: the question plus each option's `label: description` + // in `description`, the header in `title`, `oneOf` / `items.anyOf` + // choices, the scope flattened as top-level `sessionId` / `toolCallId`; + // it copies whatever value comes back into the answer, so the card's + // "Other" input reaches the model as the user's own text. Without the bit + // it denies `AskUserQuestion` for the session, so the model never gets the + // tool. Verified on the real 1.1.67 wire, MCP tool-call approvals staying + // on `session/request_permission`; the same form and answer handling ship + // in every release from 1.1.23 through 1.1.69, which matters because codeg + // launches a `qoder` found on PATH ahead of the pinned package). Agents + // without the bit fall back to their own `request_permission` button path. + if matches!( + agent_type, + AgentType::Codex | AgentType::DeepSeek | AgentType::Qoder + ) { client_capabilities = client_capabilities .elicitation(ElicitationCapabilities::new().form(ElicitationFormCapabilities::new())); } @@ -7975,7 +7992,7 @@ async fn handle_elicitation_request( let raw = req.0; // Who is on the other end, from the CONNECTION rather than from the frame. // This handler is registered for every agent and codeg advertises - // `elicitation.form` to DeepSeek as well as Codex, so the parser must be + // `elicitation.form` to DeepSeek and Qoder as well as Codex, so the parser must be // told the peer's identity instead of inferring it from `_meta.codex` — // `_meta` is an open namespace and another speaker (or an MCP server behind // it) may use the same keys for something else entirely. For a codex peer @@ -7983,13 +8000,21 @@ async fn handle_elicitation_request( // pinned at initialize: it settles which of a codex question's // `title`/`description` holds the question, which 1.12.0 swapped and the // wire cannot disambiguate. - let peer = { + let (peer, synthesizes_answer_card) = { let s = state.read().await; - if s.agent_type == AgentType::Codex { + let peer = if s.agent_type == AgentType::Codex { crate::acp::question::ElicitationPeer::Codex(s.codex_user_input_shape) } else { crate::acp::question::ElicitationPeer::Other - } + }; + // Qoder announces its AskUserQuestion as a stream `tool_call` under + // the very id this form is scoped to, and completes that call with the + // answers itself — so the card codeg synthesizes below for an ask that + // never reaches the stream would only replace that call's input with + // this form's flattened copy, and then be overwritten by Qoder's own + // completion. That completion is what renders the answers (see + // `qoder_ask_live_output`). + (peer, s.agent_type != AgentType::Qoder) }; // Everything codex-acp can send once `elicitation.form` is advertised // resolves to a plan here — an unhandled shape would silently reject the @@ -8131,7 +8156,13 @@ async fn handle_elicitation_request( // emit. Keyed by the elicitation's tool_call_id so this is // the single card for the ask and the reloaded history card // (`codex.rs`, same id) replaces rather than duplicates it. - if let Some(tool_call_id) = questions.tool_call_id.clone() { + // Not for an agent that streams the ask itself (Qoder; see + // `synthesizes_answer_card`). + if let Some(tool_call_id) = questions + .tool_call_id + .clone() + .filter(|_| synthesizes_answer_card) + { emit_with_state( &card_state, &card_emitter, @@ -8158,7 +8189,25 @@ async fn handle_elicitation_request( ) .await; } - crate::acp::question::build_elicitation_response(&questions, &outcome) + let response = + crate::acp::question::build_elicitation_response(&questions, &outcome); + // What the user actually picked, for the completion Qoder + // streams for this call to show (it flattens the answers). + // Recorded BEFORE the reply unblocks Qoder, so it is in + // place when that completion arrives. + if let Some(tool_call_id) = questions + .tool_call_id + .as_deref() + .filter(|_| !synthesizes_answer_card) + { + record_qoder_ask_reply( + &card_state, + tool_call_id, + serde_json::to_value(&response).unwrap_or_default(), + ) + .await; + } + response } // Sender dropped: canceled or the connection tore down. // Decline so codex proceeds with its own judgment. @@ -11300,6 +11349,8 @@ async fn run_conversation_loop( // follows it on the wire; one still waiting here belongs to a // call the canceled turn never completed. cb_state.claude_viewed_results.clear(); + // Same for a Qoder ask the canceled turn never completed. + cb_state.qoder_ask_questions.clear(); // A codex web search whose request never reported (canceled // mid-request) must not set aside this turn's first report. // `/goal` continuation turns run on the idle loop and announce @@ -15180,6 +15231,15 @@ struct CodeBuddyLiveState { /// dropped so the card doesn't double-render; tracked by id because a later /// status-only update may drop the `x.ai/tool` meta that first identified it. grok_ask_tool_ids: HashSet, + /// Qoder `AskUserQuestion` calls in flight → the questions they were opened + /// with. Qoder names the tool only on the opening `tool_call` + /// (`_meta.qoder.toolName`), and its completion carries the answers only as + /// the sentence handed to the model; these questions are what both the + /// reply codeg sent (keyed `q`) and that sentence are read against (see + /// `qoder_ask_live_output`). Dropped at a final status, and cleared at turn + /// start: an ask blocks its turn, so a canceled turn's entry is never + /// completed. + qoder_ask_questions: HashMap>, /// Codex sub-agent thread id → the launch capsule that spawned it, paired /// with the `rawInput` that capsule was announced with. /// @@ -15425,6 +15485,100 @@ fn grok_meta_marks_ask_user( == Some("ask_user") } +/// The questions of the Qoder `AskUserQuestion` call this opening `tool_call` +/// announces, or `None` when it announces anything else. Qoder states the +/// tool's name only here (`_meta.qoder.toolName`); the questions may be empty +/// if `rawInput` has not arrived yet, and a later frame that carries it fills +/// them in. +fn qoder_ask_opened( + agent_type: AgentType, + meta: Option<&serde_json::Map>, + raw_input: Option<&serde_json::Value>, +) -> Option> { + let names_ask = agent_type == AgentType::Qoder + && meta + .and_then(|m| m.get("qoder")) + .and_then(|q| q.get("toolName")) + .and_then(serde_json::Value::as_str) + == Some(crate::parsers::qoder::QODER_ASK_USER_QUESTION_TOOL); + names_ask.then(|| { + crate::parsers::qoder::QoderAskQuestion::list_from( + raw_input.and_then(|input| input.get("questions")), + ) + .unwrap_or_default() + }) +} + +/// The `raw_output` a Qoder `AskUserQuestion` frame should carry: its answers, +/// as the envelope the question card reads, when the frame is the answered +/// result. `None` leaves the frame's own output alone (not an ask, a +/// rejection, anything that is not an answered shape). +/// +/// Qoder answers the ask over `elicitation/create` but announces and completes +/// it as its own tool call, and nothing it streams for that call is something +/// the card can read: +/// +/// - a live call's completion carries the sentence handed to the model — in +/// `rawOutput`, and again as `content`. When codeg answered the form itself, +/// `reply` is what it sent (`SessionState::qoder_ask_replies`), which is +/// exact and wins; otherwise the sentence is read back against the questions +/// the call opened with (`parsers::qoder::qoder_ask_envelope_from_result_text`); +/// - a `session/load` replay sends the call once, already completed, with the +/// recorded `{questions, answers}` object as `rawOutput`, read the way the +/// history parser reads the transcript's copy of that object +/// (`parsers::qoder::qoder_ask_envelope_from_recorded`). +/// +/// Without this, a question the user just answered shows nothing selected. +fn qoder_ask_live_output( + questions: Option<&Vec>, + reply: Option<&serde_json::Map>, + raw_output: &Option, + content: Option<&str>, +) -> Option { + let questions = questions?; + if let Some(answers) = reply + .and_then(|reply| crate::parsers::qoder::qoder_ask_envelope_from_reply(questions, reply)) + { + return Some(answers); + } + match raw_output { + Some(recorded @ serde_json::Value::Object(_)) => { + crate::parsers::qoder::qoder_ask_envelope_from_recorded(recorded) + } + raw_output => { + let text = raw_output + .as_ref() + .and_then(serde_json::Value::as_str) + .or(content)?; + crate::parsers::qoder::qoder_ask_envelope_from_result_text(questions, text) + } + } +} + +/// Keep the reply codeg sends back for a Qoder `AskUserQuestion` call, for the +/// call's own completion to render (see `SessionState::qoder_ask_replies`). +/// Only an accepted reply carries answers; anything else keeps nothing. +async fn record_qoder_ask_reply( + state: &Arc>, + tool_call_id: &str, + reply: serde_json::Value, +) { + let serde_json::Value::Object(mut reply) = reply else { + return; + }; + if reply.get("action").and_then(serde_json::Value::as_str) != Some("accept") { + return; + } + let Some(serde_json::Value::Object(content)) = reply.remove("content") else { + return; + }; + state + .write() + .await + .qoder_ask_replies + .insert(tool_call_id.to_string(), content); +} + /// Resolve a tool call's title, honoring an authoritative rewrite recorded for /// the session in `overrides` (tool_call_id → resolved title). /// @@ -17347,6 +17501,10 @@ async fn emit_conversation_update( cb_state.grok_ask_tool_ids.insert(tool_call_id); return; } + // Qoder names an AskUserQuestion call only on this frame; its + // questions are what the frame carrying the answers is read against + // (see `qoder_ask_live_output`). + let qoder_ask = qoder_ask_opened(agent_type, tc.meta.as_ref(), tc.raw_input.as_ref()); // CodeBuddy double-wraps a deferred MCP result as a `{type,text}` // content part; peel it (in both the content and raw_output channels) // so the dedicated delegation cards parse it instead of showing raw JSON. @@ -17456,6 +17614,12 @@ async fn emit_conversation_update( // around the SAME text `content` already carries; shipping it // shadows the clean text (see opencode_live_tool_output). opencode_live_tool_output(&content, &tc.raw_output) + } else if let Some(answers) = + qoder_ask_live_output(qoder_ask.as_ref(), None, &tc.raw_output, content.as_deref()) + { + // A Qoder ask that opens already answered (a `session/load` + // replay) hands the card its answers, as its completion would. + Some(answers) } else { json_value_to_text(&tc.raw_output) .map(|text| unwrap_codebuddy_deferred_output(agent_type, &text).unwrap_or(text)) @@ -17483,6 +17647,24 @@ async fn emit_conversation_update( let meta_marks_background = codebuddy_meta_marks_background(agent_type, tc.meta.as_ref()); let grok_spawn = grok_meta_marks_spawn_subagent(agent_type, tc.meta.as_ref()); let status = format!("{:?}", tc.status).to_lowercase(); + // Kept until the frame that carries the answers; an id this frame + // reopens as anything else (or as an ask already settled) has none + // still to come. + match qoder_ask { + Some(questions) + if !matches!( + status.as_str(), + "completed" | "failed" | "cancelled" | "error" + ) => + { + cb_state + .qoder_ask_questions + .insert(tool_call_id.clone(), questions); + } + _ => { + cb_state.qoder_ask_questions.remove(&tool_call_id); + } + } // OpenCode's only authoritative statement of WHICH tool this is // arrives on this opening frame's title (see fn doc). let meta = stamp_opencode_tool_name( @@ -17658,6 +17840,31 @@ async fn emit_conversation_update( } else { None }; + // The reply codeg sent for a Qoder ask is taken by the update that + // settles the call, and shown only if that update completes it. + let qoder_ask_reply = match &tcu.fields.status { + Some(status) if cb_state.qoder_ask_questions.contains_key(&tool_call_id) => { + let status = format!("{status:?}").to_lowercase(); + if matches!(status.as_str(), "completed" | "failed") { + let reply = state.write().await.qoder_ask_replies.remove(&tool_call_id); + reply.filter(|_| status == "completed") + } else { + None + } + } + _ => None, + }; + // A Qoder ask whose questions arrive after its opening frame. + if let Some(known) = cb_state.qoder_ask_questions.get_mut(&tool_call_id) { + if let Some(questions) = crate::parsers::qoder::QoderAskQuestion::list_from( + tcu.fields + .raw_input + .as_ref() + .and_then(|input| input.get("questions")), + ) { + *known = questions; + } + } // Symmetric with the ToolCall arm. `terminal_info` only ever rides the // OPENING frame, so the id set is what identifies these updates; the // meta check is a cheap guard for a wire that ever reorders them. @@ -17794,6 +18001,15 @@ async fn emit_conversation_update( // OpenCode delivers every result on the completion update (see // opencode_live_tool_output). opencode_live_tool_output(&content, &tcu.fields.raw_output) + } else if let Some(answers) = qoder_ask_live_output( + cb_state.qoder_ask_questions.get(&tool_call_id), + qoder_ask_reply.as_ref(), + &tcu.fields.raw_output, + content.as_deref(), + ) { + // Symmetric with the ToolCall arm — and the arm that matters: + // a Qoder ask is answered on its completion update. + Some(answers) } else { json_value_to_text(&tcu.fields.raw_output) .or(viewed_result) @@ -17852,6 +18068,7 @@ async fn emit_conversation_update( Some("completed" | "failed" | "cancelled" | "error") ) { cb_state.hosted_terminal_calls.remove(&tool_call_id); + cb_state.qoder_ask_questions.remove(&tool_call_id); } // Symmetric with the ToolCall arm: an update may carry the terminal // status (and, on grok, usually re-carries the `x.ai/tool` meta). @@ -22405,6 +22622,14 @@ mod tests { assert!(deepseek.get("elicitation").is_some()); assert!(deepseek.get("_meta").is_none()); + // Qoder: form elicitation since qodercli 1.1.67's native + // `AskUserQuestion` rides `elicitation/create` (verified on the real + // wire; MCP tool-call approvals stay on `session/request_permission`). + // No `_meta` gates. + let qoder = caps_of(AgentType::Qoder); + assert!(qoder.get("elicitation").is_some()); + assert!(qoder.get("_meta").is_none()); + // Cursor: parameterized model picker only (no elicitation / AIR). let cursor = caps_of(AgentType::Cursor); assert_eq!( @@ -34298,4 +34523,590 @@ mod tests { serde_json::json!([{"type": "text", "text": "make it blue"}]) ); } + + /// Answers every question with its first option the moment it is asked. + struct AnswersFirstOption; + + #[async_trait::async_trait] + impl crate::acp::question::SessionQuestionAccess for AnswersFirstOption { + async fn register_question( + &self, + _parent_connection_id: &str, + questions: Vec, + ) -> Option { + let (tx, rx) = oneshot::channel(); + let answers = questions + .iter() + .map(|q| crate::acp::question::QuestionAnsweredItem { + question: q.question.clone(), + header: q.header.clone(), + multi_select: q.multi_select, + selected: q.options.iter().take(1).map(|o| o.label.clone()).collect(), + }) + .collect(); + let _ = tx.send(crate::acp::question::QuestionOutcome { + answers, + declined: false, + }); + Some(crate::acp::question::RegisteredQuestion { + question_id: "q-1".to_string(), + answer_rx: rx, + }) + } + + async fn cancel_question(&self, _parent_connection_id: &str, _question_id: &str) {} + + async fn cancel_questions_by_parent(&self, _parent_connection_id: &str) {} + } + + /// Drive one form `elicitation/create` from `agent_type` through + /// `handle_elicitation_request`, answered by [`AnswersFirstOption`]: the + /// reply the agent got, the events codeg recorded on the way, and the + /// session state they were recorded in. + async fn answer_form_elicitation( + agent_type: AgentType, + form: serde_json::Value, + ) -> (serde_json::Value, Vec, Arc>) { + use agent_client_protocol::Channel; + + let (client_end, agent_end) = Channel::duplex(); + let state = Arc::new(RwLock::new(SessionState::new( + "conn-test".to_string(), + agent_type, + None, + "win".to_string(), + None, + ))); + let ask_config = crate::acp::question::QuestionRuntimeConfig::new(); + ask_config + .set(crate::acp::question::QuestionConfig { enabled: true }) + .await; + let access: Option<( + Arc, + crate::acp::question::QuestionRuntimeConfig, + )> = Some((Arc::new(AnswersFirstOption), ask_config)); + + let agent = tokio::spawn(Agent.builder().connect_with( + agent_end, + async move |cx: ConnectionTo| { + cx.send_request_to(Client, CodexElicitationRequest(form)) + .block_task() + .await + }, + )); + let client = tokio::spawn({ + let state = Arc::clone(&state); + let perms = PendingPermissions::default(); + Client + .builder() + .on_receive_request( + async move |req: CodexElicitationRequest, + responder: Responder, + cx: ConnectionTo| { + handle_elicitation_request( + &cx, + &access, + &perms, + &state, + &EventEmitter::Noop, + "conn-test", + req, + responder, + ) + .await; + Ok(()) + }, + on_receive_request!(), + ) + .connect_with(client_end, async |_cx: ConnectionTo| { + std::future::pending::>().await + }) + }); + + let reply = tokio::time::timeout(std::time::Duration::from_secs(5), agent) + .await + .expect("the answered elicitation is replied to") + .expect("agent task") + .expect("a response, not an error"); + client.abort(); + let events = state + .read() + .await + .recent_events_after(0) + .map(|events| events.iter().map(|e| e.payload.clone()).collect()) + .unwrap_or_default(); + (reply, events, state) + } + + /// qodercli 1.1.67's `AskUserQuestion` form, verbatim off the wire. + fn qoder_ask_form() -> serde_json::Value { + serde_json::json!({ + "mode": "form", + "sessionId": "s1", + "toolCallId": "toolu_b857", + "message": "Answer Questions", + "requestedSchema": { + "type": "object", + "title": "Answer Questions", + "properties": {"q0": { + "type": "string", + "title": "Choice", + "description": "Pick A or B?\nA: Option A.\nB: Option B.", + "oneOf": [{"const": "A", "title": "A"}, {"const": "B", "title": "B"}], + }}, + "required": ["q0"], + }, + "_meta": {"qoder": {"toolName": "AskUserQuestion"}}, + }) + } + + /// Qoder streams its ask as a tool call of its own and completes it itself, + /// so the answered card codeg synthesizes for codex would only clobber that + /// call's input. The answer still goes back, keyed by the form's field — + /// and is kept, as sent, for that completion to show. + #[tokio::test] + async fn a_qoder_ask_is_answered_without_a_synthesized_card() { + let (reply, events, state) = + answer_form_elicitation(AgentType::Qoder, qoder_ask_form()).await; + assert_eq!( + state.read().await.qoder_ask_replies.get("toolu_b857"), + Some(serde_json::json!({"q0": "A"}).as_object().unwrap()) + ); + assert_eq!(reply["action"], "accept", "{reply}"); + assert_eq!(reply["content"], serde_json::json!({"q0": "A"}), "{reply}"); + assert!( + !events + .iter() + .any(|e| matches!(e, AcpEvent::ToolCall { .. })), + "{events:?}" + ); + } + + /// DeepSeek scopes its forms to the session alone, so there is no call to + /// key a card to either way; its answer goes back as before. + #[tokio::test] + async fn a_deepseek_ask_is_answered_without_a_card() { + let (reply, events, state) = answer_form_elicitation( + AgentType::DeepSeek, + serde_json::json!({ + "mode": "form", + "sessionId": "s1", + "message": "Pick one?", + "requestedSchema": { + "type": "object", + "properties": {"q1": { + "type": "string", + "title": "Pick", + "oneOf": [{"const": "A", "title": "A"}, {"const": "B", "title": "B"}], + }}, + "required": ["q1"], + }, + }), + ) + .await; + assert_eq!(reply["content"], serde_json::json!({"q1": "A"}), "{reply}"); + assert!(state.read().await.qoder_ask_replies.is_empty()); + assert!( + !events + .iter() + .any(|e| matches!(e, AcpEvent::ToolCall { .. })), + "{events:?}" + ); + } + + /// codex never streams `request_user_input`, so it keeps the card. + #[tokio::test] + async fn a_codex_ask_still_gets_its_answered_card() { + let (reply, events, state) = answer_form_elicitation( + AgentType::Codex, + serde_json::json!({ + "mode": "form", + "sessionId": "s1", + "toolCallId": "item-1", + "message": "Codex needs your input to continue.", + "requestedSchema": { + "type": "object", + "properties": {"q1": { + "type": "string", + "title": "Which approach?", + "description": "Approach", + "oneOf": [ + {"const": "Incremental", "title": "Incremental"}, + {"const": "Rewrite", "title": "Rewrite"}, + ], + }}, + "required": ["q1"], + }, + }), + ) + .await; + assert_eq!(reply["action"], "accept", "{reply}"); + assert!(state.read().await.qoder_ask_replies.is_empty()); + assert!( + events.iter().any(|e| matches!( + e, + AcpEvent::ToolCall { tool_call_id, title, .. } + if tool_call_id == "item-1" && title == "request_user_input" + )), + "{events:?}" + ); + } + + /// The opening `tool_call` qodercli 1.1.67 streams for an `AskUserQuestion`. + fn qoder_ask_opening_frame(tool_call_id: &str, meta: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "sessionUpdate": "tool_call", + "toolCallId": tool_call_id, + "status": "pending", + "title": "Ask user", + "kind": "other", + "content": [{"type": "content", "content": {"type": "text", "text": "{\"questions\": []}"}}], + "rawInput": {"questions": [ + {"question": "Pick A or B?", "header": "Choice", "multiSelect": false, + "options": [{"label": "A", "description": "Option A."}, {"label": "B", "description": "Option B."}]}, + {"question": "Which extras?", "header": "Extras", "multiSelect": true, + "options": [{"label": "X", "description": "Extra X."}, {"label": "Y", "description": "Extra Y."}]}, + ]}, + "_meta": meta, + }) + } + + /// The completion qodercli 1.1.67 streams once the ask settles: the same + /// text in `content` and `rawOutput`. + fn qoder_ask_completion_frame( + tool_call_id: &str, + status: &str, + text: &str, + ) -> serde_json::Value { + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": tool_call_id, + "status": status, + "content": [{"type": "content", "content": {"type": "text", "text": text}}], + "rawOutput": text, + }) + } + + const QODER_ASK_ANSWERED: &str = "Your questions have been answered: \"Pick A or B?\"=\"B\", \ + \"Which extras?\"=\"X, free text\". You can now continue with these answers in mind."; + + /// Over the real 1.1.67 frames, the completion of an answered ask reaches + /// the card as the answers — the sentence alone renders nothing selected. + #[tokio::test] + async fn a_qoder_ask_completion_carries_its_answers() { + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + let qoder_meta = serde_json::json!({"qoder": {"toolName": "AskUserQuestion"}}); + + let (_, _, opening_output, _) = pi_emit( + AgentType::Qoder, + &mut cache, + &mut cb, + qoder_ask_opening_frame("toolu_b857", qoder_meta), + ) + .await; + assert!(opening_output.is_none(), "{opening_output:?}"); + pi_emit( + AgentType::Qoder, + &mut cache, + &mut cb, + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "toolu_b857", + "status": "in_progress", + }), + ) + .await; + let (_, _, raw_output, _) = pi_emit( + AgentType::Qoder, + &mut cache, + &mut cb, + qoder_ask_completion_frame("toolu_b857", "completed", QODER_ASK_ANSWERED), + ) + .await; + + let envelope: serde_json::Value = + serde_json::from_str(&raw_output.expect("the answers")).unwrap(); + assert_eq!( + envelope, + serde_json::json!({ + "answers": [ + {"header": "Choice", "question": "Pick A or B?", "selected": ["B"]}, + {"header": "Extras", "question": "Which extras?", "selected": ["X, free text"]}, + ], + "declined": false, + }) + ); + assert!( + cb.qoder_ask_questions.is_empty(), + "settled asks are forgotten" + ); + } + + /// A `session/load` replay sends an answered ask once, already completed, + /// with Qoder's recorded `{questions, answers}` object as `rawOutput` (the + /// 1.1.67 replay of the session above, verbatim); it reaches the card as + /// the same answers the transcript gives. + #[tokio::test] + async fn a_replayed_qoder_ask_carries_its_recorded_answers() { + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + let mut replayed = qoder_ask_opening_frame( + "toolu_b857", + serde_json::json!({"qoder": {"toolName": "AskUserQuestion"}}), + ); + replayed["status"] = serde_json::json!("completed"); + replayed["rawOutput"] = serde_json::json!({ + "questions": replayed["rawInput"]["questions"].clone(), + "answers": {"Pick A or B?": "B", "Which extras?": "X, free text"}, + }); + + let (_, _, raw_output, _) = pi_emit(AgentType::Qoder, &mut cache, &mut cb, replayed).await; + + let envelope: serde_json::Value = + serde_json::from_str(&raw_output.expect("the answers")).unwrap(); + assert_eq!( + envelope, + serde_json::json!({ + "answers": [ + {"header": "Choice", "question": "Pick A or B?", "selected": ["B"]}, + {"header": "Extras", "question": "Which extras?", "selected": ["X, free text"]}, + ], + "declined": false, + }) + ); + assert!( + cb.qoder_ask_questions.is_empty(), + "a settled ask is not kept" + ); + } + + /// Drive frames through one session, as a live connection does, and + /// return the last tool-call event's `raw_output`. + async fn qoder_frames( + state: &Arc>, + cb: &mut CodeBuddyLiveState, + frames: Vec, + ) -> Option { + let mut cache = ToolCallOutputCache::default(); + for frame in frames { + let update: SessionUpdate = serde_json::from_value(frame).unwrap(); + emit_conversation_update( + state, + &EventEmitter::Noop, + AgentType::Qoder, + update, + None, + &mut cache, + cb, + ) + .await; + } + let guard = state.read().await; + guard + .recent_events_after(0) + .unwrap_or_default() + .iter() + .rev() + .find_map(|e| match &e.payload { + AcpEvent::ToolCall { raw_output, .. } + | AcpEvent::ToolCallUpdate { raw_output, .. } => Some(raw_output.clone()), + _ => None, + }) + .flatten() + } + + fn qoder_session() -> Arc> { + Arc::new(RwLock::new(SessionState::new( + "conn-q".to_string(), + AgentType::Qoder, + None, + "win".to_string(), + None, + ))) + } + + /// When codeg answered the form, the completion shows what it sent: the + /// chosen label and the user's own text apart, which the sentence Qoder + /// streams (`"X, free text"`) can no longer tell. + #[tokio::test] + async fn a_qoder_ask_completion_shows_the_reply_codeg_sent() { + let state = qoder_session(); + let mut cb = CodeBuddyLiveState::default(); + let opening = qoder_ask_opening_frame( + "toolu_b857", + serde_json::json!({"qoder": {"toolName": "AskUserQuestion"}}), + ); + qoder_frames(&state, &mut cb, vec![opening]).await; + state.write().await.qoder_ask_replies.insert( + "toolu_b857".to_string(), + serde_json::json!({"q0": "B", "q1": ["X", "free text"]}) + .as_object() + .unwrap() + .clone(), + ); + + let raw_output = qoder_frames( + &state, + &mut cb, + vec![ + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "toolu_b857", + "status": "in_progress", + }), + qoder_ask_completion_frame("toolu_b857", "completed", QODER_ASK_ANSWERED), + ], + ) + .await; + + let envelope: serde_json::Value = + serde_json::from_str(&raw_output.expect("the answers")).unwrap(); + assert_eq!( + envelope["answers"], + serde_json::json!([ + {"header": "Choice", "question": "Pick A or B?", "selected": ["B"]}, + {"header": "Extras", "question": "Which extras?", "selected": ["X", "free text"]}, + ]) + ); + assert!(state.read().await.qoder_ask_replies.is_empty(), "taken"); + } + + /// A reply is shown only by the completion it was sent for: a call that + /// fails instead drops it and keeps Qoder's own output, and an update that + /// does not settle the call leaves it in place. + #[tokio::test] + async fn a_qoder_ask_reply_is_shown_only_by_its_completion() { + let state = qoder_session(); + let mut cb = CodeBuddyLiveState::default(); + let opening = qoder_ask_opening_frame( + "toolu_b857", + serde_json::json!({"qoder": {"toolName": "AskUserQuestion"}}), + ); + qoder_frames(&state, &mut cb, vec![opening]).await; + state.write().await.qoder_ask_replies.insert( + "toolu_b857".to_string(), + serde_json::json!({"q0": "B"}).as_object().unwrap().clone(), + ); + + qoder_frames( + &state, + &mut cb, + vec![serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "toolu_b857", + "status": "in_progress", + })], + ) + .await; + assert_eq!(state.read().await.qoder_ask_replies.len(), 1, "still owed"); + + let failure = "Error: the question tool failed."; + let raw_output = qoder_frames( + &state, + &mut cb, + vec![qoder_ask_completion_frame("toolu_b857", "failed", failure)], + ) + .await; + assert_eq!(raw_output.as_deref(), Some(failure)); + assert!(state.read().await.qoder_ask_replies.is_empty(), "dropped"); + } + + /// What a reconnect hydrates from — the session's own record of the call — + /// agrees with what streamed: Qoder's original questions stay the input, + /// and the output is the answers, not the sentence. + #[tokio::test] + async fn a_qoder_ask_settles_with_its_questions_and_answers() { + let state = Arc::new(RwLock::new(SessionState::new( + "conn-q".to_string(), + AgentType::Qoder, + None, + "win".to_string(), + None, + ))); + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + let opening = qoder_ask_opening_frame( + "toolu_b857", + serde_json::json!({"qoder": {"toolName": "AskUserQuestion"}}), + ); + let questions = opening["rawInput"].clone(); + for frame in [ + opening, + serde_json::json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": "toolu_b857", + "status": "in_progress", + }), + qoder_ask_completion_frame("toolu_b857", "completed", QODER_ASK_ANSWERED), + ] { + let update: SessionUpdate = serde_json::from_value(frame).unwrap(); + emit_conversation_update( + &state, + &EventEmitter::Noop, + AgentType::Qoder, + update, + None, + &mut cache, + &mut cb, + ) + .await; + } + + let guard = state.read().await; + let call = guard + .active_tool_calls + .get("toolu_b857") + .expect("the ask is recorded"); + assert_eq!(call.input.as_ref(), Some(&questions)); + let Some(crate::acp::session_state::ToolCallOutput::Json { value }) = &call.output else { + panic!("the answers are the output: {:?}", call.output); + }; + assert_eq!(value["answers"][0]["selected"], serde_json::json!(["B"])); + assert_eq!(value["declined"], false); + } + + /// Only an answered Qoder ask is rewritten: a declined one keeps Qoder's + /// rejection, and neither another Qoder tool nor another agent carrying the + /// same `_meta` is taken for an ask. + #[tokio::test] + async fn only_an_answered_qoder_ask_is_rewritten() { + let rejection = "The user doesn't want to proceed with this tool use. The tool use \ + was rejected."; + let qoder_meta = serde_json::json!({"qoder": {"toolName": "AskUserQuestion"}}); + let other_tool = serde_json::json!({"qoder": {"toolName": "Bash"}}); + for (agent_type, meta, status, text) in [ + (AgentType::Qoder, qoder_meta.clone(), "failed", rejection), + ( + AgentType::Qoder, + other_tool, + "completed", + QODER_ASK_ANSWERED, + ), + ( + AgentType::ClaudeCode, + qoder_meta, + "completed", + QODER_ASK_ANSWERED, + ), + ] { + let mut cache = ToolCallOutputCache::default(); + let mut cb = CodeBuddyLiveState::default(); + pi_emit( + agent_type, + &mut cache, + &mut cb, + qoder_ask_opening_frame("toolu_1", meta), + ) + .await; + let (_, _, raw_output, _) = pi_emit( + agent_type, + &mut cache, + &mut cb, + qoder_ask_completion_frame("toolu_1", status, text), + ) + .await; + assert_eq!(raw_output.as_deref(), Some(text), "{agent_type:?} {status}"); + } + } } diff --git a/src-tauri/src/acp/question.rs b/src-tauri/src/acp/question.rs index b7ca779698..09e8c08e1d 100644 --- a/src-tauri/src/acp/question.rs +++ b/src-tauri/src/acp/question.rs @@ -976,19 +976,20 @@ pub enum CodexUserInputShape { /// The identity has to come from the connection, not from the payload. `_meta` /// is an OPEN namespace: the ACP spec tells implementations to make no /// assumptions about keys they do not own, and this parser is shared — codeg -/// advertises `elicitation.form` to DeepSeek as well as Codex, and EITHER -/// adapter also forwards arbitrary MCP-server forms through it. Deciding "this -/// is codex" from `_meta.codex.*` in the request would let a third party, by -/// coincidence or on purpose, switch on codex's orientation flip, its companion -/// skip and its synthetic-option filter for a form codex never sent — which can -/// hide a legitimate field or change how an answer is encoded. +/// advertises `elicitation.form` to DeepSeek and Qoder as well as Codex, and +/// codex-acp and deepseek-acp also forward arbitrary MCP-server forms through +/// it. Deciding "this is codex" from `_meta.codex.*` in the request would let a +/// third party, by coincidence or on purpose, switch on codex's orientation +/// flip, its companion skip and its synthetic-option filter for a form codex +/// never sent — which can hide a legitimate field or change how an answer is +/// encoded. /// /// So `Other` is absolute: no codex fixup runs for it, whatever `_meta` claims. /// Only inside `Codex` does the payload get a say, and then only about WHICH /// generation sent it. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ElicitationPeer { - /// Anything that is not codex-acp (DeepSeek today; any future + /// Anything that is not codex-acp (DeepSeek and Qoder today; any future /// `elicitation.form` speaker). The form is parsed generically. Other, /// codex-acp, carrying the `request_user_input` shape its RUNNING version @@ -1300,7 +1301,8 @@ pub fn classify_elicitation( } // Carry the elicitation's tool_call_id so the connection handler can key the // synthesized in-stream result card to it — codex delivers `request_user_input` - // ONLY as this elicitation, never as a stream tool_call. + // ONLY as this elicitation, never as a stream tool_call. (Qoder streams its + // ask as a tool_call of its own, so the handler synthesizes no card there.) questions.tool_call_id = tool_call_id; Ok(ElicitationPlan::Questions(questions)) } @@ -1408,8 +1410,8 @@ fn parse_form_questions( // `title`/`description` carries the question (see [`codex_form_shape`]). let codex_shape = codex_form_shape(raw, peer); // The 1.12.0-only fixups below apply to exactly one generation of exactly - // one adapter; everything else — DeepSeek, generic MCP servers — shares this - // parser and must come through untouched. + // one adapter; everything else — DeepSeek, Qoder, generic MCP servers — + // shares this parser and must come through untouched. let is_codex_112_form = codex_shape == Some(CodexUserInputShape::QuestionInTitle); // `_meta.codex` is only codex's to interpret. On any other peer the same // keys are an unrelated extension that must not silently eat a field. @@ -1882,6 +1884,63 @@ mod tests { ); } + /// qodercli 1.1.67's native `AskUserQuestion` form, verbatim off the wire + /// (raw ACP probe, 2026-10-10): the question prose — with qoder's own + /// option explanations appended — is in `description`, the short header in + /// `title`, choices are `oneOf` const/title pairs, and the scope is + /// flattened top-level (`sessionId` + `toolCallId`). The crate's flatten + /// lands that in `ElicitationSessionScope`, so the toolCallId is read like + /// any form's: it names the `Ask user` `tool_call` qoder streams just + /// before the elicitation, which is why the handler synthesizes no card + /// for qoder (see `handle_elicitation_request`). + #[test] + fn classify_elicitation_reads_qoder_ask_user_question_form() { + let raw = serde_json::json!({ + "mode": "form", + "sessionId": "8c706dde-7ac9-4ac9-824d-29ec8179d734", + "toolCallId": "call_d341dc678fad4b7a8ea5f42e", + "message": "Answer Questions", + "requestedSchema": { + "type": "object", + "title": "Answer Questions", + "properties": { + "q0": { + "type": "string", + "title": "选择", + "description": "选 A 还是 B?\nA: 选择 A 选项。\nB: 选择 B 选项。", + "oneOf": [ + {"const": "A", "title": "A"}, + {"const": "B", "title": "B"} + ] + } + }, + "required": ["q0"] + }, + "_meta": {"qoder": {"toolName": "AskUserQuestion"}} + }); + let q = expect_questions(classify_elicitation(&raw, ElicitationPeer::Other).unwrap()); + assert_eq!(q.specs.len(), 1); + assert_eq!(q.specs[0].id, "q0", "property key becomes the spec id"); + assert_eq!( + q.specs[0].question, + "选 A 还是 B?\nA: 选择 A 选项。\nB: 选择 B 选项。" + ); + assert_eq!(q.specs[0].header, "选择"); + assert!(!q.specs[0].multi_select); + let labels: Vec<_> = q.specs[0] + .options + .iter() + .map(|o| o.label.as_str()) + .collect(); + assert_eq!(labels, ["A", "B"]); + assert_eq!(q.fields[0].kind, ElicitationFieldKind::Text); + assert_eq!( + q.tool_call_id.as_deref(), + Some("call_d341dc678fad4b7a8ea5f42e"), + "the flattened scope's toolCallId is read" + ); + } + /// codex-acp ≥1.12.0's `request_user_input` shape, verbatim off /// `buildUserInputRequest`: the QUESTION is in `title`, the short tab label /// is in `description`, the companion is `_note` with diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index dd929c095a..9ace2248d0 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -265,6 +265,16 @@ pub struct SessionState { pub status: ConnectionStatus, pub live_message: Option, pub active_tool_calls: BTreeMap, + /// The answers codeg sent back for Qoder `AskUserQuestion` calls whose + /// completion has not arrived yet, keyed by tool-call id: the elicitation + /// reply's `content` as written (`q` → the chosen label, or a + /// multi-select's chosen labels and the user's own text as separate + /// entries). Qoder's completion carries the answers only flattened into a + /// sentence, where a multi-select can no longer be told apart from typed + /// text, so the live path renders the call from this instead. Written by + /// the elicitation handler before it replies, taken by the update that + /// settles the call; per-turn, like `active_tool_calls`. + pub qoder_ask_replies: BTreeMap>, pub pending_permission: Option, /// The agent's in-flight `ask_user_question` (one set of multiple-choice @@ -709,6 +719,7 @@ impl SessionState { status: ConnectionStatus::Connecting, live_message: None, active_tool_calls: BTreeMap::new(), + qoder_ask_replies: BTreeMap::new(), pending_permission: None, pending_question: None, pending_plan_approval: None, @@ -1175,6 +1186,7 @@ impl SessionState { } self.live_message = None; self.active_tool_calls.clear(); + self.qoder_ask_replies.clear(); // The turn's user prompt is no longer "in flight" — the // assistant reply is done and the transcript is the source of // truth. Clear it so a post-turn snapshot doesn't carry a stale @@ -1595,6 +1607,7 @@ impl SessionState { self.agent_initiated_turn = true; self.live_message = None; self.active_tool_calls.clear(); + self.qoder_ask_replies.clear(); true } diff --git a/src-tauri/src/parsers/qoder.rs b/src-tauri/src/parsers/qoder.rs index 763d1d6af8..83bdfd2e4a 100644 --- a/src-tauri/src/parsers/qoder.rs +++ b/src-tauri/src/parsers/qoder.rs @@ -743,7 +743,7 @@ fn parse_transcript(bytes: &[u8]) -> Transcript { Some(display) => vec![ContentBlock::Text { text: display.clone(), }], - None => extract_user_content(value), + None => with_recorded_ask_answers(value, extract_user_content(value)), }; if content.is_empty() { continue; @@ -929,6 +929,347 @@ fn is_human_prompt(value: &Value) -> bool { } } +/// A `user` record's content, with an answered `AskUserQuestion` result +/// carrying the answers Qoder recorded (`toolUseResult`) rather than the +/// sentence it handed the model (see [`qoder_ask_envelope_from_recorded`]). +/// +/// Qoder writes one tool result per `user` record, and `toolUseResult` belongs +/// to it; with any other number of results the pairing is not certain, so the +/// content is left as it is. +fn with_recorded_ask_answers(record: &Value, mut content: Vec) -> Vec { + let mut results = content + .iter_mut() + .filter(|block| matches!(block, ContentBlock::ToolResult { .. })); + let ( + Some(ContentBlock::ToolResult { + output_preview, + is_error: false, + .. + }), + None, + ) = (results.next(), results.next()) + else { + return content; + }; + if let Some(envelope) = record + .get("toolUseResult") + .and_then(qoder_ask_envelope_from_recorded) + { + *output_preview = Some(envelope); + } + content +} + +/// Qoder's native question tool, by the name it records in transcripts and +/// stamps on its live `tool_call` (`_meta.qoder.toolName`). +pub(crate) const QODER_ASK_USER_QUESTION_TOOL: &str = "AskUserQuestion"; + +/// One question of a Qoder `AskUserQuestion` call, as the call's own input +/// states it: `questions[i]` of the live `tool_call`'s `rawInput`, and of the +/// `toolUseResult` the transcript records next to the result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct QoderAskQuestion { + question: String, + header: String, + multi_select: bool, + labels: Vec, +} + +impl QoderAskQuestion { + /// The questions of an `AskUserQuestion` input, in order. `None` unless + /// every entry carries its `question` text: that text is what Qoder keys + /// each answer by, so a list missing one cannot be matched to the answers. + pub(crate) fn list_from(questions: Option<&Value>) -> Option> { + let parsed = questions? + .as_array()? + .iter() + .map(|item| { + Some(Self { + question: item.get("question")?.as_str()?.to_string(), + header: item + .get("header") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + multi_select: item + .get("multiSelect") + .and_then(Value::as_bool) + .unwrap_or(false), + labels: item + .get("options") + .and_then(Value::as_array) + .map(|options| { + options + .iter() + .filter_map(|option| option.get("label").and_then(Value::as_str)) + .filter(|label| !label.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_default(), + }) + }) + .collect::>>()?; + (!parsed.is_empty()).then_some(parsed) + } + + /// One RECORDED answer back as the picks the question card partitions + /// against the options, claiming no choice the record cannot prove. + /// + /// A single-select answer is one pick, verbatim: an offered label or the + /// user's own text. A multi-select answer reaches Qoder as a list, but + /// Qoder keeps it as one string, `values.join(", ")`, with the user's own + /// "Other" text in that same list (codeg's card writes it after the chosen + /// labels; see `crate::acp::question::build_elicitation_response`). So: + /// + /// - read exactly one way as offered labels joined by ", " → those labels; + /// - not readable as labels at all (the user's own text is in it) → the + /// literal text, one pick, which the card shows as typed text — it + /// cannot equal a label, or it would have read as one; + /// - readable several ways (options like "Red", "Green" and "Red, Green") + /// → the literal text if it is no label, and no pick at all if it is + /// one, since the card would show that label as chosen. + /// + /// A live call codeg answered itself does not need this: it renders the + /// reply as sent, before Qoder flattened it (see + /// [`qoder_ask_envelope_from_reply`]). + fn picks(&self, answer: &str) -> Vec { + if answer.is_empty() { + return Vec::new(); + } + if !self.multi_select { + return vec![answer.to_string()]; + } + match self.label_reading(answer) { + LabelReading::Unique(labels) => labels, + LabelReading::Unreadable => vec![answer.to_string()], + LabelReading::Ambiguous if self.labels.iter().any(|label| label == answer) => { + Vec::new() + } + LabelReading::Ambiguous => vec![answer.to_string()], + } + } + + /// How `answer` reads as offered labels joined by ", ". + /// + /// Counted per suffix from the end, capped at two (all [`Self::picks`] + /// needs is none, one, or several), so the cost stays linear in the + /// answer's length even when labels are joins of one another ("A", + /// "A, A") and a long answer could otherwise be split in exponentially + /// many ways. + fn label_reading(&self, answer: &str) -> LabelReading { + let mut labels: Vec<&str> = self.labels.iter().map(String::as_str).collect(); + labels.sort_unstable(); + labels.dedup(); + let end = answer.len(); + // How many readings `label`, placed at byte `at`, opens: none unless + // it ends the answer (one) or is followed by ", " and a suffix that + // reads (as many as that suffix does). + let through = |readings: &[u8], at: usize, label: &str| -> u8 { + if !answer[at..].starts_with(label) { + return 0; + } + let after = at + label.len(); + if after == end { + 1 + } else if answer[after..].starts_with(", ") { + readings[after + 2] + } else { + 0 + } + }; + // `readings[at]`: the readings of `answer[at..]`, 2 meaning "several". + let mut readings = vec![0u8; end + 1]; + for at in (0..end).rev().filter(|&at| answer.is_char_boundary(at)) { + readings[at] = labels + .iter() + .map(|label| through(&readings, at, label)) + .fold(0u8, |total, count| total.saturating_add(count).min(2)); + } + match readings[0] { + 0 => LabelReading::Unreadable, + 1 => { + // Exactly one label opens the only reading at each step. + let mut picks = Vec::new(); + let mut at = 0; + while let Some(label) = labels + .iter() + .find(|label| through(&readings, at, label) > 0) + { + picks.push((*label).to_string()); + at += label.len() + 2; + if at > end { + break; + } + } + LabelReading::Unique(picks) + } + _ => LabelReading::Ambiguous, + } + } +} + +/// How a recorded multi-select answer reads as the offered labels; see +/// [`QoderAskQuestion::picks`]. +enum LabelReading { + /// Not as labels at all: the user's own text is in it. + Unreadable, + /// Exactly one way. + Unique(Vec), + /// More than one way, so no reading can be trusted. + Ambiguous, +} + +/// The answers codeg itself sent back for a Qoder `AskUserQuestion`, as the +/// envelope the question card reads. +/// +/// This is the one source that is exact: Qoder's form keys question `n` as +/// `q` and codeg's reply writes a multi-select's chosen labels and the +/// user's own text as separate entries, before Qoder flattens them into one +/// string for everything it records or streams (see [`QoderAskQuestion::picks`]). +/// `questions` are the call's own, in order, as its opening `tool_call` +/// carried them. +pub(crate) fn qoder_ask_envelope_from_reply( + questions: &[QoderAskQuestion], + reply: &serde_json::Map, +) -> Option { + let answered: Vec<_> = questions + .iter() + .enumerate() + .filter_map(|(index, question)| { + let picks: Vec = match reply.get(&format!("q{index}"))? { + Value::String(answer) => vec![answer.clone()], + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .map(String::from) + .collect(), + _ => return None, + }; + let picks: Vec = picks.into_iter().filter(|pick| !pick.is_empty()).collect(); + (!picks.is_empty()).then_some((question, picks)) + }) + .collect(); + (!answered.is_empty()).then(|| qoder_ask_envelope(answered)) +} + +/// The answer envelope codeg's question card reads (`parseAskQuestionOutcome` +/// in `src/lib/ask-question.ts`): the `{answers: [{header, question, selected}], +/// declined}` shape codeg-mcp's `ask_user_question` records, and the one the +/// OpenCode and Grok history parsers rebuild for their own native asks. +fn qoder_ask_envelope(answered: Vec<(&QoderAskQuestion, Vec)>) -> String { + let answers: Vec = answered + .into_iter() + .map(|(question, selected)| { + serde_json::json!({ + "header": question.header, + "question": question.question, + "selected": selected, + }) + }) + .collect(); + serde_json::json!({ "answers": answers, "declined": false }).to_string() +} + +/// The answers Qoder recorded for an `AskUserQuestion` call, as the envelope +/// the question card reads: `{questions, answers: {"": ""}}`, +/// one string per answered question (a multi-select flattened to one +/// `", "`-joined string, which [`QoderAskQuestion::picks`] splits only when +/// that is unambiguous). +/// +/// Qoder keeps this object in two places: the transcript record that carries +/// the call's result (`toolUseResult`), and the `rawOutput` of the single, +/// already-completed `tool_call` a `session/load` replay sends for the call. +/// Beside it is only the sentence Qoder hands the model (see +/// [`qoder_ask_envelope_from_result_text`]), which the card cannot read, so +/// without this every answered question reloads with nothing selected. +/// +/// `None` for anything else, including a declined ask, whose recorded result +/// is only `{isHardFailure}`; and when no question was answered, which leaves +/// the call's own output in place. +pub(crate) fn qoder_ask_envelope_from_recorded(recorded: &Value) -> Option { + let questions = QoderAskQuestion::list_from(recorded.get("questions"))?; + let answers = recorded.get("answers")?.as_object()?; + let answered: Vec<_> = questions + .iter() + .filter_map(|question| { + let picks = match answers.get(&question.question)? { + Value::String(answer) => question.picks(answer), + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .map(String::from) + .collect(), + _ => return None, + }; + (!picks.is_empty()).then_some((question, picks)) + }) + .collect(); + (!answered.is_empty()).then(|| qoder_ask_envelope(answered)) +} + +/// The start and end of the sentence Qoder's `AskUserQuestion` returns once +/// questions are answered (deobfuscated from the 1.1.67 bundle; the same +/// strings ship in every release from 1.1.23 through 1.1.69). +const QODER_ANSWERED_PREFIX: &str = "Your questions have been answered: "; +const QODER_ANSWERED_SUFFIX: &str = ". You can now continue with these answers in mind."; + +/// The answers in the result text Qoder's completion frame carries for an +/// `AskUserQuestion` call, read against the questions the call was opened +/// with, as the envelope the question card reads: the live path's fallback +/// for a call codeg did not answer itself (when it did, the reply it sent is +/// exact; see [`qoder_ask_envelope_from_reply`]). +/// +/// That frame's `rawOutput` and `content` are both the sentence handed to the +/// model: +/// `Your questions have been answered: "Q1"="A1", "Q2"="B, C". You can now +/// continue with these answers in mind.`, one entry per answered question in +/// question order (`AskUserQuestion.execute`). Knowing the question texts is +/// what lets each answer be cut out whole: an entry opens with +/// `""="` and its answer runs to the next answered question's +/// `", ""="` or to the closing quote, so quotes or ", " inside a +/// question or an answer do not split it. The answer text is then the same +/// string the transcript records, and splits into picks by the same rule +/// ([`QoderAskQuestion::picks`]). +/// +/// `None`, leaving the frame's own output in place, for any text that is not +/// that sentence: a rejection, the "did not answer" sentence, a future +/// rewording, or entries that do not line up with the questions. +pub(crate) fn qoder_ask_envelope_from_result_text( + questions: &[QoderAskQuestion], + text: &str, +) -> Option { + let mut rest = text + .trim() + .strip_prefix(QODER_ANSWERED_PREFIX)? + .strip_suffix(QODER_ANSWERED_SUFFIX)?; + let mut answered = Vec::new(); + for (index, question) in questions.iter().enumerate() { + let Some(entry) = rest.strip_prefix(&format!("\"{}\"=\"", question.question)) else { + // Unanswered questions have no entry. + continue; + }; + let next_entry = questions[index + 1..] + .iter() + .filter_map(|later| entry.find(&format!("\", \"{}\"=\"", later.question))) + .min(); + let answer = match next_entry { + Some(end) => { + // Past the `", ` that closes this entry and separates the next. + rest = &entry[end + 3..]; + &entry[..end] + } + None => { + let answer = entry.strip_suffix('"')?; + rest = ""; + answer + } + }; + answered.push((question, question.picks(answer))); + } + (rest.is_empty() && !answered.is_empty()).then(|| qoder_ask_envelope(answered)) +} + impl AgentParser for QoderParser { fn list_conversations(&self) -> Result, ParseError> { let mut conversations = Vec::new(); @@ -1963,4 +2304,268 @@ mod tests { let percent = s.context_window_usage_percent.expect("percent"); assert!((percent - (10.0 / 262_144.0) * 100.0).abs() < 1e-9, "{percent}"); } + + // An `AskUserQuestion` round captured from a real qodercli 1.1.67 ACP + // session (driven over `elicitation/create` with `--fake-responses`; usage, + // anchors and paths trimmed): a single-select and a multi-select question, + // answered "B" and "X" plus the user's own text. + const ASK_PROMPT_LINE: &str = r#"{"type":"user","uuid":"q-u1","timestamp":"2026-10-12T23:56:20.355Z","message":{"role":"user","content":[{"type":"text","text":"Ask me a question."}]},"permissionMode":"default","origin":{"kind":"human"},"promptId":"p1","parentUuid":null,"isSidechain":false,"cwd":"/w","sessionId":"s1","userType":"external","entrypoint":"acp","version":"unknown","gitBranch":"HEAD"}"#; + const ASK_TOOL_USE_LINE: &str = r#"{"type":"assistant","uuid":"q-a1","timestamp":"2026-10-12T23:56:20.647Z","message":{"id":"msg_1","type":"message","role":"assistant","model":"auto","stop_reason":"tool_use","stop_sequence":null,"content":[{"type":"tool_use","id":"toolu_b857","name":"AskUserQuestion","input":{"questions":[{"question":"Pick A or B?","header":"Choice","options":[{"label":"A","description":"Option A."},{"label":"B","description":"Option B."}],"multiSelect":false},{"question":"Which extras?","header":"Extras","options":[{"label":"X","description":"Extra X."},{"label":"Y","description":"Extra Y."}],"multiSelect":true}]}}]},"parentUuid":"q-u1","isSidechain":false,"cwd":"/w","sessionId":"s1","userType":"external","entrypoint":"acp","version":"unknown","gitBranch":"HEAD"}"#; + const ASK_RESULT_LINE: &str = r#"{"type":"user","uuid":"q-u2","timestamp":"2026-10-12T23:56:20.751Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_b857","content":"Your questions have been answered: \"Pick A or B?\"=\"B\", \"Which extras?\"=\"X, free text\". You can now continue with these answers in mind."}]},"sourceToolAssistantUUID":"q-a1","promptId":"p1","toolUseResult":{"questions":[{"question":"Pick A or B?","header":"Choice","options":[{"label":"A","description":"Option A."},{"label":"B","description":"Option B."}],"multiSelect":false},{"question":"Which extras?","header":"Extras","options":[{"label":"X","description":"Extra X."},{"label":"Y","description":"Extra Y."}],"multiSelect":true}],"answers":{"Pick A or B?":"B","Which extras?":"X, free text"}},"parentUuid":"q-a1","isSidechain":false,"cwd":"/w","sessionId":"s1","userType":"external","entrypoint":"acp","version":"unknown","gitBranch":"HEAD"}"#; + // The same ask declined: Qoder rejects the call like any denied tool. + const ASK_DECLINED_LINE: &str = r#"{"type":"user","uuid":"q-u2","timestamp":"2026-10-12T23:56:20.751Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_b857","content":"The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). Do not repeat this call; take a different approach, or if you cannot proceed without it, tell the user what you were trying to do and why you need it.","is_error":true}]},"sourceToolAssistantUUID":"q-a1","promptId":"p1","toolUseResult":{"isHardFailure":false},"toolDenialKind":"user-rejected","parentUuid":"q-a1","isSidechain":false,"cwd":"/w","sessionId":"s1","userType":"external","entrypoint":"acp","version":"unknown","gitBranch":"HEAD"}"#; + const ASK_DONE_LINE: &str = r#"{"type":"assistant","uuid":"q-a2","timestamp":"2026-10-12T23:56:20.880Z","message":{"id":"msg_2","type":"message","role":"assistant","model":"auto","stop_reason":"end_turn","stop_sequence":null,"content":[{"type":"text","text":"done","citations":null}]},"parentUuid":"q-u2","isSidechain":false,"cwd":"/w","sessionId":"s1","userType":"external","entrypoint":"acp","version":"unknown","gitBranch":"HEAD"}"#; + + fn ask_result_output(result_line: &str) -> (Option, bool) { + let tmp = tempfile::tempdir().unwrap(); + write_session( + tmp.path(), + "s1", + &[ + ASK_PROMPT_LINE, + ASK_TOOL_USE_LINE, + result_line, + ASK_DONE_LINE, + ], + ); + let detail = parser_in(tmp.path()).get_conversation("s1").unwrap(); + detail + .turns + .iter() + .flat_map(|turn| turn.blocks.iter()) + .find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + output_preview, + is_error, + .. + } if tool_use_id.as_deref() == Some("toolu_b857") => { + Some((output_preview.clone(), *is_error)) + } + _ => None, + }) + .expect("the ask's result is in the transcript") + } + + // The question card cannot read the sentence Qoder hands the model, so an + // answered ask has to reload carrying the answers Qoder recorded beside it + // — or every question shows nothing selected. "X" plus the user's own text + // is one `", "`-joined string by now, and stays the text the agent got. + #[test] + fn an_answered_ask_reloads_with_its_recorded_answers() { + let (output, is_error) = ask_result_output(ASK_RESULT_LINE); + assert!(!is_error); + let envelope: Value = serde_json::from_str(&output.expect("output")).unwrap(); + assert_eq!( + envelope, + serde_json::json!({ + "answers": [ + {"header": "Choice", "question": "Pick A or B?", "selected": ["B"]}, + {"header": "Extras", "question": "Which extras?", "selected": ["X, free text"]}, + ], + "declined": false, + }) + ); + } + + // A declined ask keeps Qoder's own rejection: there are no answers to show. + #[test] + fn a_declined_ask_keeps_its_rejection() { + let (output, is_error) = ask_result_output(ASK_DECLINED_LINE); + assert!(is_error); + assert!(output + .expect("output") + .starts_with("The user doesn't want to proceed with this tool use.")); + } + + fn ask_questions(questions: Value) -> Vec { + QoderAskQuestion::list_from(Some(&questions)).expect("questions") + } + + // A multi-select answer is one `", "`-joined string. It splits back into + // picks only when every part is an offered label (a label containing ", " + // staying whole); with the user's own text in it, "Blue, teal" could be + // Blue plus "teal" or the text "Blue, teal", so it stays the literal text + // rather than claim a choice the user may have typed. + #[test] + fn a_multi_select_answer_splits_only_into_offered_labels() { + let questions = ask_questions(serde_json::json!([{ + "question": "Colors?", + "header": "Colors", + "multiSelect": true, + "options": [{"label": "Red, Green"}, {"label": "Red"}, {"label": "Blue"}], + }])); + assert_eq!( + questions[0].picks("Red, Green, Blue"), + ["Red, Green", "Blue"] + ); + assert_eq!(questions[0].picks("Blue, Red"), ["Blue", "Red"]); + assert_eq!( + questions[0].picks("Red, Green, Blue, teal, maybe"), + ["Red, Green, Blue, teal, maybe"] + ); + assert_eq!(questions[0].picks("Bluest"), ["Bluest"]); + assert_eq!(questions[0].picks("my own"), ["my own"]); + assert!(questions[0].picks("").is_empty()); + + // "Red, Green" is both an option and Red plus Green: nothing it shows + // as chosen could be trusted, so it claims nothing — and the text, + // being an option itself, cannot stand in either. + let questions = ask_questions(serde_json::json!([{ + "question": "Colors?", + "multiSelect": true, + "options": [{"label": "Red, Green"}, {"label": "Red"}, {"label": "Green"}], + }])); + assert!(questions[0].picks("Red, Green").is_empty()); + // Several readings again, but the text is no option: it stays the text. + let questions = ask_questions(serde_json::json!([{ + "question": "Colors?", + "multiSelect": true, + "options": [{"label": "Red, Green"}, {"label": "Red"}, {"label": "Green"}, + {"label": "Blue"}], + }])); + assert_eq!(questions[0].picks("Red, Green, Blue"), ["Red, Green, Blue"]); + assert_eq!(questions[0].picks("Blue, Red"), ["Blue", "Red"]); + } + + // Labels that are joins of each other read a long answer in exponentially + // many ways; counting readings per suffix keeps that linear, and every + // outcome still claims nothing it cannot prove. + #[test] + fn self_similar_labels_are_read_in_linear_time() { + let questions = ask_questions(serde_json::json!([{ + "question": "Pick?", + "multiSelect": true, + "options": [{"label": "A"}, {"label": "A, A"}], + }])); + // Thousands of "A" before the user's own text: unreadable as labels, + // so the literal text — after a linear pass, not a combinatorial one. + let long = format!("{}custom", "A, ".repeat(5_000)); + assert_eq!(questions[0].picks(&long), [long.as_str()]); + // A, A is the option "A, A" or A twice; the text is an option itself, + // so nothing is claimed. + assert!(questions[0].picks("A, A").is_empty()); + // Several readings, and the text is no option: it stays the text. + assert_eq!(questions[0].picks("A, A, A"), ["A, A, A"]); + assert_eq!(questions[0].picks("A"), ["A"]); + } + + // What codeg itself sent back is exact: each question's own entry, chosen + // labels and the user's own text kept apart. + #[test] + fn the_reply_codeg_sent_is_read_as_sent() { + let questions = ask_questions(serde_json::json!([ + {"question": "Pick A or B?", "header": "Choice", "options": [{"label": "A"}, {"label": "B"}]}, + {"question": "Skipped?", "header": "Skip", "options": [{"label": "No"}]}, + {"question": "Which extras?", "header": "Extras", "multiSelect": true, + "options": [{"label": "X"}, {"label": "Y"}]}, + ])); + let reply = serde_json::json!({"q0": "B", "q2": ["X", "free, text"]}); + let envelope: Value = serde_json::from_str( + &qoder_ask_envelope_from_reply(&questions, reply.as_object().unwrap()) + .expect("envelope"), + ) + .unwrap(); + assert_eq!( + envelope, + serde_json::json!({ + "answers": [ + {"header": "Choice", "question": "Pick A or B?", "selected": ["B"]}, + {"header": "Extras", "question": "Which extras?", "selected": ["X", "free, text"]}, + ], + "declined": false, + }) + ); + assert_eq!( + qoder_ask_envelope_from_reply(&questions, &serde_json::Map::new()), + None + ); + } + + // A single-select answer is one pick, whatever punctuation it carries. + #[test] + fn a_single_select_answer_is_never_split() { + let questions = ask_questions(serde_json::json!([{ + "question": "Pick?", + "options": [{"label": "A"}, {"label": "B"}], + }])); + assert_eq!(questions[0].picks("A, B"), ["A, B"]); + } + + // The live completion carries only the sentence; read against the known + // questions it gives the same envelope the transcript does — quotes and + // ", " inside a question or an answer included, unanswered questions left + // out. + #[test] + fn the_answered_sentence_reads_back_against_its_questions() { + let questions = ask_questions(serde_json::json!([ + {"question": "Rename \"foo\"?", "header": "Rename", "options": [{"label": "Yes"}]}, + {"question": "Skipped?", "header": "Skip", "options": [{"label": "No"}]}, + {"question": "Which extras?", "header": "Extras", "multiSelect": true, + "options": [{"label": "X"}, {"label": "Y"}]}, + {"question": "Which\nlines?", "header": "Lines", "multiSelect": true, + "options": [{"label": "X"}, {"label": "Y"}]}, + ])); + let text = concat!( + "Your questions have been answered: ", + "\"Rename \"foo\"?\"=\"say \"hi\", then go\", ", + "\"Which extras?\"=\"X, Y, free text\", ", + "\"Which\nlines?\"=\"Y, X\". ", + "You can now continue with these answers in mind.", + ); + let envelope: Value = serde_json::from_str( + &qoder_ask_envelope_from_result_text(&questions, text).expect("envelope"), + ) + .unwrap(); + assert_eq!( + envelope, + serde_json::json!({ + "answers": [ + {"header": "Rename", "question": "Rename \"foo\"?", "selected": ["say \"hi\", then go"]}, + {"header": "Extras", "question": "Which extras?", "selected": ["X, Y, free text"]}, + {"header": "Lines", "question": "Which\nlines?", "selected": ["Y", "X"]}, + ], + "declined": false, + }) + ); + + // Only a later question answered: the earlier ones have no entry. + let text = "Your questions have been answered: \"Which extras?\"=\"Y\". You can now \ + continue with these answers in mind."; + let envelope: Value = serde_json::from_str( + &qoder_ask_envelope_from_result_text(&questions, text).expect("envelope"), + ) + .unwrap(); + assert_eq!( + envelope["answers"], + serde_json::json!([{"header": "Extras", "question": "Which extras?", "selected": ["Y"]}]) + ); + } + + // Anything but the answered sentence is left as it came. + #[test] + fn other_result_text_is_not_read_as_answers() { + let questions = ask_questions(serde_json::json!([ + {"question": "Pick A or B?", "options": [{"label": "A"}, {"label": "B"}]}, + ])); + for text in [ + "The user doesn't want to proceed with this tool use. The tool use was rejected.", + "The user did not answer the questions.", + // An entry for a question this call never asked. + "Your questions have been answered: \"Other?\"=\"A\". You can now continue with \ + these answers in mind.", + "Your questions have been answered: . You can now continue with these answers in \ + mind.", + ] { + assert_eq!( + qoder_ask_envelope_from_result_text(&questions, text), + None, + "{text}" + ); + } + assert!( + QoderAskQuestion::list_from(Some(&serde_json::json!([{"header": "No text"}]))) + .is_none() + ); + } }