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
31 changes: 24 additions & 7 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4757,13 +4757,22 @@ 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 ≥1.1.67 (verified 2026-10-10 against the real
// wire: its native `AskUserQuestion` arrives as a form — question prose in
// `description`, header in `title`, `oneOf` choices, scope flattened as
// top-level `sessionId`/`toolCallId` — while MCP tool-call approvals KEEP
// using `session/request_permission`, so only the question surface
// changes; without the bit qodercli hides `AskUserQuestion` entirely and
// the model falls back to its `request_permission` path). 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()));
}
Expand Down Expand Up @@ -22405,6 +22414,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!(
Expand Down
52 changes: 52 additions & 0 deletions src-tauri/src/acp/question.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1882,6 +1882,58 @@ 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 keys the
/// synthesized result card for the `Ask user` `tool_call` frame qoder
/// emits immediately before the elicitation.
#[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"),
"flattened scope's toolCallId keys the synthesized result card"
);
}

/// 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 `<id>_note` with
Expand Down
Loading